betsy-button

Family health button
git clone https://git.woozle.org/neale/betsy-button.git

betsy-button / web
Neale Pickett  ·  2025-06-29

programmer.mjs

  1import {Terminal} from "https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm"
  2import * as esptool from "https://unpkg.com/esptool-js/bundle.js"
  3import jsMd5 from "https://cdn.jsdelivr.net/npm/js-md5@0.8.3/+esm"
  4
  5// Files we need to install
  6let installFiles = {}
  7let term = new Terminal()
  8
  9function Print(...val) {
 10    term.write(val.join(" "))
 11}
 12
 13function Println(...val) {
 14    return Print(...val, "\r\n")
 15}
 16
 17function Log(...val) {
 18    Println("\x1b[33;1m✦", ...val, "\x1b[0m")
 19}
 20
 21function Error(...val) {
 22    Println("\x1b[37;41;1m=== ERROR === ", ...val, "\x1b[0m")
 23}
 24
 25function sleep(duration) {
 26    return new Promise(resolve => setTimeout(resolve, duration))
 27}
 28
 29function terminalInput(s) {
 30    if (!window.conn?.port?.connected) {
 31        return
 32    }
 33
 34    window.conn.write(s)
 35}
 36
 37class Connection {
 38    constructor(port) {
 39        this.port = port
 40
 41        const decoderStream = new TextDecoderStream()
 42        this.readableStreamClosed = this.port.readable.pipeTo(decoderStream.writable)
 43        this.reader = decoderStream.readable.getReader()
 44        this.writer = this.port.writable.getWriter()
 45
 46        this.watchers = []
 47        this.point = 0
 48        this._readLoop()
 49    }
 50
 51    async _readLoop() {
 52        let buf = ""
 53        while (true) {
 54            let resp = await this.reader.read()
 55            if (resp.done) {
 56                break
 57            }
 58            Print(resp.value)
 59            buf += resp.value
 60
 61            // Look for anything we're supposed to be looking for
 62            let newWatchers = []
 63            for (let watcher of this.watchers) {
 64                let i = buf.indexOf(watcher.match, watcher.point)
 65                if (i == -1) {
 66                    newWatchers.push(watcher)
 67                    continue
 68                }
 69                this.point = i
 70                watcher.resolve(i)
 71            }
 72            this.watchers = newWatchers
 73        }
 74        this.reader.releaseLock()
 75        this.writer.releaseLock()
 76        for (let watcher of this.watchers) {
 77            watcher.reject()
 78        }
 79        Log("disconnected")
 80    }
 81
 82    /**
 83     * Block until match is read.
 84     *
 85     * @param {string} match Text to match
 86     * @returns {Promise}
 87     */
 88    emits(match) {
 89        let {promise, resolve, reject} = Promise.withResolvers()
 90        let watcher = {
 91            match,
 92            resolve,
 93            reject,
 94            point: this.point,
 95        }
 96        this.watchers.push(watcher)
 97        return promise
 98    }
 99
100    write(str) {
101        let enc = new TextEncoder()
102        let buf = enc.encode(str)
103        this.writer.write(buf)
104    }
105    writeln(str) {
106        this.write(str + "\r\n")
107    }
108
109    writeFile(name, data) {
110        const linelen = 40
111        let data64 = btoa(data)
112        this.writeln(`f = open("${name}", "wb")`)
113        while (data64.length > 0) {
114            let line = data64.substring(0, linelen)
115            this.writeln(`_=f.write(binascii.a2b_base64("${line}"))`)
116            data64 = data64.substring(linelen)
117        }
118        this.writeln(`f.close()`)
119    }
120}
121
122function updateButtons() {
123    let connected = window.conn?.port?.connected ?? false
124    let complete = true
125    for (let e of document.querySelectorAll("[required]")) {
126        if (!e.value) {
127            complete = false
128        }
129    }
130
131    document.querySelector("#connect").disabled = connected
132    document.querySelector("#bootstrap").disabled = connected
133    document.querySelector("#program").disabled = !connected || !complete
134}
135
136async function disconnect(event) {
137    Error("Lost connection")
138    updateButtons()
139}
140
141function setPort(port) {
142    let info = port.getInfo()
143    let vid = info.usbVendorId.toString(16).padStart(4, '0')
144    let pid = info.usbProductId.toString(16).padStart(4, '0')
145    Log(`Connected to device ${vid}:${pid}`)
146    Log(`Fill in fields and click "Program"`)
147
148    window.conn = new Connection(port)
149    updateButtons()
150}
151
152async function connectButton(event) {
153    let port = await navigator.serial.requestPort({
154        filters: [
155            {usbVendorId: 0x303a, usbProductId: 0x1001}, // Seeeduino XIAO ESP32-C3
156        ]
157    })
158    port.addEventListener("disconnect", disconnect)
159    await port.open({
160        baudRate: 115200,
161    })
162    setPort(port)
163}
164
165function buildConfig() {
166    let config = {}
167
168    let id = document.querySelector("[name=id]").value
169    if (id.startsWith("https://")) {
170        config.url = id
171    } else {
172        config.url = `https://woozle.org/betsy/state/${id}`
173    }
174
175    config.networks = {}
176    for (let network of document.querySelectorAll(".network")) {
177        let ssid = network.querySelector("[name=ssid]").value
178        let psk = network.querySelector("[name=psk]").value
179        if (ssid) {
180            config.networks[ssid] = psk
181        }
182    }
183    return config
184}
185
186async function programButton(event) {
187    let conn = window.conn
188    installFiles["config.json"] = JSON.stringify(buildConfig())
189    conn.write("\x03")          // ^C
190    await conn.emits(">>> ")
191    conn.write("\x03")          // Another one. for good measure
192    await conn.emits(">>> ")
193    conn.writeln("import binascii, machine")
194    await conn.emits(">>> ")
195    for (let [name, buf] of Object.entries(installFiles)) {
196        Log(`Uploading ${name}`)
197        conn.writeFile(name, buf)
198        await conn.emits(">>> ")
199    }
200    conn.writeln("machine.reset()")
201}
202
203async function bootstrapButton(event) {
204    for (let b of document.querySelectorAll("button.serial")) {
205        b.disabled = true
206    }
207
208    try {
209        let name = "ESP32_GENERIC_C3-20250415-v1.25.0.bin"
210        let resp = await fetch(name, {cache: "no-cache"})
211        if (!resp.ok) {
212            Error(`${name}: ${resp.status} ${resp.statusText}`)
213            return
214        }
215        let arr = await resp.arrayBuffer()
216        let prog = new Uint8Array(arr)
217
218        // This library insists on starting with an unconnected port
219        let port = await navigator.serial.requestPort({
220            filters: [
221                {usbVendorId: 0x303a, usbProductId: 0x1001}, // Seeeduino XIAO ESP32-C3
222            ]
223        })
224        port.addEventListener("disconnect", disconnect)
225        let loader = new esptool.ESPLoader({
226            port,
227            baudrate: 921600,
228            terminal: {
229                clean: () => {},
230                writeLine: b => term.writeln(b),
231                write: b => term.write(b),
232            },
233        })
234        let chip = await loader.main()
235        await loader.writeFlash({
236            fileArray: [
237                {address: 0, data: loader.ui8ToBstr(prog)},
238            ],
239            flashSize: "keep",
240            eraseAll: false,
241            compress: true,
242            enableTracing: false,
243            calculateMD5Hash: buf => jsMd5.md5(loader.bstrToUi8(buf)),
244        })
245        await loader.after()
246        Log("Bootstrap complete.")
247        
248        await loader.transport.disconnect()
249        Log("Press the R button on the device, then click the Connect button above.")
250    }
251    catch (err) {
252        Error(err)
253    }
254    finally {
255        updateButtons()
256    }
257}
258
259async function init() {
260    document.querySelector("#connect").addEventListener("click", connectButton)
261    document.querySelector("#bootstrap").addEventListener("click", bootstrapButton)
262    document.querySelector("#program").addEventListener("click", programButton)
263    for (let e of document.querySelectorAll("input")) {
264        e.addEventListener("input", updateButtons)
265    }
266    term.open(document.querySelector("#terminal"))
267    term.onData(terminalInput)
268    updateButtons()
269
270    for (let name of ["blinker.py", "main.py"]) {
271        let resp = await fetch(name, {cache: "no-cache"})
272        if (!resp.ok) {
273            Error(`${name}: ${resp.status} ${resp.statusText}`)
274            return
275        }
276        installFiles[name] = await resp.text()
277    }
278    Log(`Programmer ready`)
279    Log(`Click "Bootstrap" to bootstrap a device for the first time`)
280    Log(`Click "Connect" to connect your device, so you can program it`)
281    updateButtons()
282
283    if (localStorage.ssid) {
284        document.querySelector("[name=ssid]").value = localStorage.ssid
285        document.querySelector("[name=psk]").value = localStorage.psk
286        document.querySelector("[name=id]").value = localStorage.id
287    }
288}
289
290init()