pnpm --filter @datler/sensors build:firmware writes packages/sensors/dist/firmware.ram.js and packages/sensors/dist/firmware.installed.js and prints each one’s size, through buildFirmware in packages/sensors/src/firmware-build.ts:

  1. esbuild bundles packages/sensors/firmware/main.ts and what it imports, the format code in packages/sensors/src/format/ among it, keeping Espruino’s own require as it is.
  2. deviceScript (packages/sensors/src/transpile.ts) turns it into ES5, the part of JavaScript any Espruino version runs.
  3. The two builds part:
    • The test build is minified one top-level statement to a line, because a Puck holds the line it is running in RAM while it builds the functions in it: sent as one 7.2 KB line, the firmware ran out of memory loading; as 30 lines, the longest 3.4 KB, it loaded and left 1,147 of 2,630 blocks free (measured 2026-10-20). Its top-level names live in Espruino’s global scope, so buildFirmware refuses a name that would replace one of Espruino’s globals or pin names (ESPRUINO_GLOBALS).
    • The installed build is one function, minified, so its names stay out of Espruino’s global scope; Espruino runs installed code from flash in place, so its length costs no RAM to read.

Espruino’s console stores no history of a line over 500 characters, or of one sent with the no-echo prefix (src/jsinteractive.c), so what is sent costs no RAM once it has run. Its regular expressions are a subset of JavaScript’s, without \d{6}, so the firmware checks such things by hand.

The build sets INSTALLED. Only the installed build has the code that writes Storage; the test build keeps its log in RAM, and buildFirmware refuses to emit a test build that mentions Storage, so firmware loaded for a test cannot write the device’s flash. It also refuses one that needs a TypeScript helper the Puck does not have.

packages/sensors/firmware/main.ts is the one file that touches Espruino’s globals. The rest of packages/sensors/firmware/ reaches the device through the Hal interface in packages/sensors/firmware/core.ts, so it runs under test on the host. packages/sensors/firmware/espruino.d.ts declares the Espruino globals the firmware uses and nothing more, and packages/sensors/firmware/tsconfig.json checks the firmware, and the format code it imports, against ES5’s library alone, so it cannot reach for something the Puck does not have.

Memory

A Puck.js v2.1 on firmware 2v29 has 2,630 memory blocks, 2,562 of them free after reset() (measured 2026-10-20). Espruino keeps a function’s source in RAM, so code costs its size twice while it loads, once as the line being run and once in the functions it makes, and a carried module twice more, cached and then evaluated. A 7.4 KB build that carried Espruino’s BTHome and puckjsv2-accel-steps modules ran out of memory building its first advert. Without them, with the advert encoded by packages/sensors/src/format/bthome.ts and the step detector’s register writes inlined in packages/sensors/firmware/pedometer.ts, the build is 5.5 KB, and 1,332 blocks were free once it was loaded and running, with Espruino’s pretokenise flag on, which keeps functions as tokens rather than text.

So the firmware carries no Espruino module. One worth using is inlined, with its source and licence named where it lands.

Loading it for a test

pnpm --filter @datler/sensors dev serves the dev page (packages/sensors/tools/dev/) at localhost:4299, a secure context, so Web Bluetooth works there, and builds the test build fresh on every request; it restarts itself when its own code changes. The page connects, sends reset() (which clears RAM, not flash) and E.setFlags({pretokenise:1}), sends the firmware line by line, each prefixed \x10 so the Puck does not echo it, in 20-byte writes that wait for a response (5.5 KB took about 4 seconds), then checks the firmware answers: memory, DS.hello, DS.time, the button live, and the log read back as readings. It writes what it saw to packages/sensors/dist/dev-report.json.

Installing it

The owner installs the firmware from the dev page with Install, on a Puck connected over Web Bluetooth; an agent never presses it (Puck.js). The page fetches the installed build and, through packages/sensors/src/install.ts:

  1. sends reset(), which clears RAM, and turns pretokenise off, since installed code runs from flash in place and a pretokenised function would be copied into RAM;
  2. writes the build into .bootcde, the file Espruino runs at every start, in pieces of 256 characters, one require("Storage").write each, the first creating the file at its full size, so the Puck never holds the whole script in RAM (the build is ASCII, so the pieces’ offsets count bytes, and installCommands refuses one that is not);
  3. checks the file’s length and E.CRC32 against the build’s, and erases it when they differ;
  4. sends load(), which starts what is in .bootcde, then DS.hello(), whose log.pct above 0 says the installed firmware answered, and sets the clock.

Uninstall erases .bootcde and restarts, leaving the log’s segments and ds.config, so the next install carries on the same log. Espruino’s hard reset clears everything (Gestures, Pairing).

The installed firmware keeps in ds.config its log’s budget, its epoch, its passkey and its gestures. A log’s segments are the Storage files named ds. and a record number (segmentStarts in packages/sensors/firmware/log.ts); the epoch is kept only while one of them is, so a log that lost every segment begins again under a new epoch and no reader takes it for the old one.

packages/sensors/src/installed-firmware.test.ts runs the installed build on the host the way a Puck does, from its boot file, with Espruino’s globals faked and Storage held in memory: the install writes the build exactly and its check catches a byte changed, and the passkey, gestures, epoch and records outlast a restart.