Skip to content

Instantly share code, notes, and snippets.

@ddlsmurf
Last active December 21, 2021 17:25
Show Gist options
  • Save ddlsmurf/8e9c0621d6b6a27929c92c3b1c1fad34 to your computer and use it in GitHub Desktop.
Save ddlsmurf/8e9c0621d6b6a27929c92c3b1c1fad34 to your computer and use it in GitHub Desktop.
Watchy BLE OTA client #watchy #tool
<html>
<head>
<style>
.dragging {
background-color: rgb(236, 135, 135);
}
</style>
</head>
<body>
<h1>Watchy BLE firmware upload client</h1>
<select id="selFacePresets"></select>
<br />
<input type="text" id="txtURL" placeholder="Or enter a URL here"/><button id="btnUseURL">Load</button>
<div id="dropStatus">or drop a file anywhere on this page</div>
<pre id="bleStatus"></pre>
<script>
const dropStatus = document.getElementById("dropStatus");
const drop = document.body;
drop.ondragover = (evt) => evt.preventDefault();
drop.ondragenter = () => drop.classList.add("dragging");
drop.ondragleave = drop.ondragend = () => drop.classList.remove("dragging");
drop.ondrop = (evt) => {
drop.ondragleave();
evt.preventDefault();
if (typeof FileReader === "undefined")
return alert("Browser does not support FileReader");
const files = evt.dataTransfer.files;
if (files.length != 1)
return alert("Only drop one file at a time");
const reader = new FileReader();
dropStatus.innerText = "Reading dropped file " + files[0].name;
// Note: addEventListener doesn't work in Google Chrome for this event
reader.onload = () => {
dropStatus.innerText = `Read ${files[0].name} it has ${reader.result.byteLength} bytes`;
ble.startUpload(reader.result);
}
reader.onerror = (e) => {
dropStatus.innerText = "";
console.error(e);
alert("Error reading file " + e);
}
reader.readAsArrayBuffer(files[0]);
};
let ble = (function () {
const SERVICE_UUID_ESPOTA = "cd77498e-1ac8-48b6-aba8-4161c7342fce";
const CHARACTERISTIC_UUID_ID = "cd77498f-1ac8-48b6-aba8-4161c7342fce";
const SERVICE_UUID_OTA = "86b12865-4b70-4893-8ce6-9864fc00374d";
const CHARACTERISTIC_UUID_FW = "86b12866-4b70-4893-8ce6-9864fc00374d";
const CHARACTERISTIC_UUID_HW_VERSION = "86b12867-4b70-4893-8ce6-9864fc00374d";
const CHARACTERISTIC_UUID_WATCHFACE_NAME = "86b12868-4b70-4893-8ce6-9864fc00374d";
const FULL_PACKET = 512;
const result = { };
const State = result.State = {
Failed: -1,
Idle: 0,
Downloading: 1,
Downloaded: 2,
Scanning: 3,
Connecting: 4,
Connected: 5,
Uploading: 6,
WaitingForReset: 7,
};
result.stateToString = (state) => Object.keys(State).map(k => state == State[k] ? k : null).filter(x => x)[0] || `Unknown state (${state})`
let runningState = {};
function onStatus(state, stateData) {
runningState = state == State.Idle ? {} : Object.assign({}, runningState, stateData);
if (result.onStatus) return result.onStatus(state, runningState);
}
const arrayOfSize = (size, map) => Array(size).fill(0).map((_, i) => map(i));
async function getVersion(service) {
const characteristic = await service.getCharacteristic(CHARACTERISTIC_UUID_HW_VERSION);
const value = await characteristic.readValue();
const readVersion = (offset, count) => arrayOfSize(count, i => value.getUint8(offset + i)).join('.');
return {
hw: readVersion(0, 2),
sw: readVersion(2, 3),
};
}
function newTriggeredWaitable() {
let resolver = null;
const reinit = _ => result.next = new Promise((r) => resolver = r);
const result = {
trigger(v) {
let prev = resolver;
reinit();
prev(v);
}
};
reinit();
return result;
}
async function getWatchFaceName(service) {
const characteristic = await service.getCharacteristic(CHARACTERISTIC_UUID_WATCHFACE_NAME);
const value = await characteristic.readValue();
return arrayOfSize(value.byteLength, i => String.fromCharCode(value.getUint8(i))).join('');
}
async function uploadFirmware(service, data) {
const size = data.byteLength;
let remaining = size;
const characteristic = await service.getCharacteristic(CHARACTERISTIC_UUID_FW);
const writeReady = newTriggeredWaitable();
const sendNextBlock = async function() {
const amountToWrite = Math.min(remaining, FULL_PACKET);
const offset = size - remaining;
const dataToSend = data.slice(offset, offset + amountToWrite);
await characteristic.writeValue(dataToSend);
onStatus(State.Uploading, { sent: offset + amountToWrite });
remaining -= amountToWrite;
if (remaining > 0)
await writeReady.next;
};
onStatus(State.Uploading, { size, sent: 0 });
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', writeReady.trigger);
try {
while (remaining > 0)
await sendNextBlock();
} finally {
characteristic.removeEventListener('characteristicvaluechanged', writeReady.trigger);
}
}
async function delay(ms) { return new Promise(r => setTimeout(_ => r(), ms || 0)); }
result.startUpload = async function(firmwareData) {
onStatus(State.Scanning);
await delay();
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID_ESPOTA] }],
optionalServices: [SERVICE_UUID_OTA]
});
onStatus(State.Connecting, { name: device.name });
const server = await device.gatt.connect();
try {
const service = await server.getPrimaryService(SERVICE_UUID_OTA);
const version = await getVersion(service);
const faceName = await getWatchFaceName(service);
onStatus(State.Connected, { version, faceName });
await uploadFirmware(service, firmwareData);
} catch (error) {
onStatus(State.Failed, {error});
} finally {
onStatus(State.WaitingForReset);
await delay(10000); // Not sure what's up, but if disconnect is too early, reset goes to menu
await server.disconnect();
onStatus(State.Idle);
}
};
result.startUploadURL = async function(url) {
onStatus(State.Downloading, { url });
let resp = await fetch(url);
let content = await resp.arrayBuffer();
onStatus(State.Downloaded, { dlSize: content.byteLength });
ble.startUpload(content);
};
return result;
})();
const bleStatus = document.getElementById("bleStatus");
ble.onStatus = (state, data) => {
if (state != ble.State.Uploading)
console.log(ble.stateToString(state), data);
if (typeof data.size == "number" && typeof data.sent == "number")
data.progress = ((data.sent * 100) / data.size).toFixed(2) + " %";
bleStatus.innerText = "BLE: " + ble.stateToString(state) + "\n" + JSON.stringify(data, null, 2);
}
const cmp = (a, b) => a.localeCompare(b, 'en', {ignorePunctuation: true, sensitivity: 'base' });
const non0 = (a, b) => a == 0 ? b : a
async function loadDefaultFaces() {
const facesFetch = await fetch("https://raw.githubusercontent.com/sqfmi/watchy-docs/main/src/pages/watchfaces/watchfaces.json");
let faces = await facesFetch.json();
function el(parent, tag, attr, text) {
const el = document.createElement(tag);
for (p in (attr || {})) if (attr && attr.hasOwnProperty(p))
el.setAttribute(p, attr[p]);
if (text) el.innerText = text;
if (parent) parent.appendChild(el);
}
const sel = document.getElementById("selFacePresets");
el(sel, "option", { disabled: "", selected: ""}, "Pick standard face here");
faces = faces.filter(x => x.ota_bin);
faces.sort((a, b) => non0(cmp(a.name, b.name), cmp(a.author, b.author)));
const urlForFace = (face) => {
const dir = face == '7_SEG_LIGHT' ? '7_SEG' : face; // todo: <- not ok, fix this exception
return 'https://raw.githubusercontent.com/sqfmi/Watchy/master/examples/WatchFaces/' + dir + '/' + face + '.bin';
};
faces.forEach(face => el(sel, "option", { value: urlForFace(face.name) }, face.name + " by " + face.author));
sel.onchange = async e => ble.startUploadURL(sel.value);
}
loadDefaultFaces();
document.getElementById("btnUseURL").onclick = _ => ble.startUploadURL(document.getElementById("txtURL").value)
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment