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()