Skip to content

Feat/device plane - #11

Closed
decoded-cipher wants to merge 45 commits into
masterfrom
feat/device-plane
Closed

decoded-cipher wants to merge 45 commits into
masterfrom
feat/device-plane

Conversation

@decoded-cipher

Copy link
Copy Markdown
Owner

This pull request introduces browser-based code editing and firmware flashing for hardware devices, along with a robust mechanism for keeping deployment configuration files (wrangler.toml) in sync with upstream changes while preserving deployment-specific settings. It also extends block and trigger definitions to support device selection and adds new dependencies to support the code editor and flashing features. The most important changes are grouped below:


Browser-based Code Editing and Flashing

  • Added a new page and flow for writing, building, and flashing firmware to hardware devices directly from the browser, using a local agent for compilation and Web Serial for flashing. This is documented in the README.md and enabled by new dependencies and the CodeEditor.vue component. [1] [2] [3] [4] [5]
  • Introduced the esptool-js dependency and Web Serial typings to enable cross-platform flashing from the browser. [1] [2]

Deployment Configuration Sync (wrangler.toml merging)

  • Replaced the previous approach of preserving the entire wrangler.toml with a new script (merge-wrangler.ts) that merges deployment identity (name, resource IDs, routes, etc.) into the latest upstream template, ensuring new bindings and flags reach all deployments without breaking existing ones. [1] [2] [3] [4] [5]
  • Added comprehensive tests for the merge logic in merge-wrangler.test.ts to guarantee correctness and idempotency.
  • Updated deploy/wrangler.toml comments to clarify the new merging strategy.

API and Shared Types Enhancements

  • Added a new api.bytes method for fetching binary data (e.g., firmware images) from the backend, supporting the flashing workflow. [1] [2]
  • Extended the block field types and triggers to support device selection, allowing automations to target specific devices. [1] [2]
  • Updated the grid layout normalization to include device information.

Documentation and Architecture

  • Expanded the README.md with detailed instructions for setting up the agent, flashing hardware from the browser, and the updated architecture diagram including the agent. [1] [2]

These changes collectively enable a seamless browser-based development and deployment workflow for hardware devices, improve maintainability of deployment configurations, and enhance the flexibility of the automation system.

Statements ran one at a time, so a failure part-way left the schema
half-applied and the retry restarted from the first statement. That is
only safe while every statement is idempotent — the first ALTER TABLE
would turn a partial failure into a permanent failure loop on an instance
nobody can see.

db.batch() is a real transaction. The applied_at write rides inside it,
so d1_migrations can never record a migration that did not commit.
Durable Objects get no migration runner, so each class now carries an
ordered list of schema changes and walks itself forward inside
transactionSync() the first time it wakes after a deploy. One that throws
rolls back rather than stranding storage between versions, and the
constructor runs before any method, so no caller sees a half-migrated
object.

The baseline is the existing schema unchanged, so an object that predates
versioning replays it as a no-op and converges on the version a fresh
object reaches. SchedulerDO is key-value only and needs none of this.
The build preserved each deployment's wrangler.toml verbatim, which froze
its topology at whatever the Deploy button wrote on day one: a binding,
cron or compatibility flag added upstream never reached anyone who had
already deployed.

Bindings, flags and build config now come from upstream's carrier
template, while the Worker name, account, routes, resource ids and vars
come from the deployment. Taking upstream's name instead would create a
second Worker and orphan the live one, so identity is merged in by
binding rather than by position.

The build script is fetched from master while the source clone is the
latest release tag, so a release predating the merge script falls back to
the previous behaviour instead of failing.
The monitor holds an exclusive reader on the port and the flasher needs it
closed, so both go through claim(), which tears down the read loop, closes
the port, hands it over, and restores the monitor at its previous baud. Two
owners racing for the same lock is what produces "port already open" errors
that survive a reload.

Console lines are source-tagged, because flash progress never arrives over
the port — esptool-js speaks binary to the ROM loader and reports separately.

Web Serial is absent from TypeScript's DOM lib, hence the types dependency.
Splits the stream into levels the console can colour: SDK lines by their
message, ESP-IDF lines by their severity letter, boot ROM output by its
prefixes, and anything else as plain sketch output.

Entries commit once per animation frame rather than per line — a board at
115200 baud can outrun per-line reactivity. Pausing holds new lines aside
instead of dropping them.

Tracks whether recent output is mostly replacement and control characters,
which is what a wrong baud rate looks like.
A Device section under each project, with a console that connects a board
over Web Serial and shows what it prints. Output follows the tail only while
the view is at the bottom — a chatty board otherwise makes it impossible to
read back through a fault.

Browsers without Web Serial get a page naming the ones that do and saying
this is the only part of nodrix that needs USB, rather than a dead button.

The hub takes the tabbed shape Variables and Automations already use, with
one tab for now.
Turns SDK debug output into a banner above the console — "Token rejected",
"The server refused the connection" — instead of leaving a red line for the
reader to interpret. The newest significant line wins, so recovering from a
fault clears the stale error.

Wi-Fi state is tracked separately from cloud state, which is what makes a
compound reading possible: "Wi-Fi is up" in front of a server failure says
the radio is fine and the problem is further along.

diagnose() takes an entry array and holds no reactive state, so the rules
are tested directly rather than through a mounted component.
Adds a devices table and a default device per project, then rebuilds
project_variables with device_id in its unique key. Telemetry that names no
device lands on the default, so a plain curl with only a token keeps working.

project_variables is rebuilt rather than altered because device_id is NOT
NULL with a foreign key and the unique key changes shape. Nothing references
that table by foreign key, so the rebuild needs no deferral.

Default device ids derive from the project id, which keeps the migration
deterministic without generating ids in SQL, and one default per project is
enforced by a partial unique index rather than left to convention.

Tested against SQLite: an instance seeded before the upgrade and a fresh
install come out with byte-identical schemas, and replaying the baseline over
a migrated database does not reinstate the old two-column unique index.
Boards identify themselves with an X-Nodrix-Device header; the value maps to
a device row, created on first sight. Anything that names no device lands on
the project's default, so an existing board or a plain curl keeps working
untouched.

The reported key is untrusted input — normalised, length-capped, and bounded
at 100 devices per project so a board that reports a fresh key every boot
can't grow the table without limit. It is kept apart from the device id so
renaming stays free and a MAC never surfaces in anything a user reads.

Device creation reads back after inserting rather than trusting the id it
generated: two isolates racing on the same first boot both insert, one loses
the conflict, and returning its own id would attribute telemetry to a row
that does not exist.

createProject now writes the project and its default device in one batch.
The migration only covers projects that already existed, so without this a
project created after upgrading would have no default device at all.
latest_state is rebuilt on (device_id, variable), ring_buffer and
pending_control gain the column, and the ring index follows.

'' marks the project's default device and every pre-devices row backfills to
it. NULL cannot serve there: SQLite treats NULLs as distinct in a unique key,
so the upsert would never match and each write would append a duplicate row
instead of updating one. pending_control does use NULL, where it means a
control write with no target that still broadcasts.

R2 keeps its shape — the device goes in the NDJSON row, not the key path, so
an hour isn't fragmented into one small object per device. Rows written
before this read back as the default device.

Series reads take an optional device filter; omitting it queries the whole
project, which is what dashboards still do.
Ingest now carries the device through to Durable Object storage, and /state
groups by device rather than returning a flat key map. Those had to change
together: two boards reporting the same key into a flat map means the second
silently overwrites the first, which is the whole point of having devices.

resolveDevice returns both ids it is asked for — the D1 id that owns the
variable rows, and the storage id the DO keys by, which is '' for the default
device. Callers get what they need without either of them knowing the rule.

Adds list, rename and forget. The default device can't be forgotten; without
one, telemetry that names no device has nowhere to land. Forgetting drops D1
rows and DO state but leaves R2 history, which is only safe because the
device is named in each row rather than in the key path.
Adds a hello frame carrying the device key and optional chip and firmware.
An all-WS board never sends an HTTP header, so without this it had no way to
say which board it was and everything landed on the default device.

The socket's device is held in serializeAttachment rather than memory: a
hibernated Durable Object keeps the attachment but loses anything in an
isolate, so an idle board that wakes hours later stays attributed.

Connect and hello split the pending-control flush. A socket has to catch up
the moment it connects, but its identity isn't known until hello arrives, so
it starts as the default device and takes broadcasts and default-device
writes, then re-scopes and drains its own queue on hello. The two queries
can't overlap, and a hello that fails to resolve returns rather than falling
through to the default.

Control writes can now target one device; a null target still broadcasts.
Devices becomes the first tab under Device and the console moves alongside
it. Renaming matters more than it looks: a board identifies itself by
something MAC-derived, and without a name that string would surface in every
variable a user reads.

The default device is badged and has no Forget control, mirroring the server
rule rather than trusting the client to know it.

The forget confirmation spells out what happens, because none of it is
guessable: variables and recent history go, archived telemetry stays, and the
board reappears if it ever reports again.

Focus on the rename input is set from a function ref. A template ref declared
inside v-for collects into an array — the compiler decides that by lexical
position, so the v-if narrowing it to one row makes no difference — and
calling focus() on the array would have thrown.
Variable triggers take an optional device, defaulting to any. Both readings
are real — one rule per place, or one rule for every place — and neither was
expressible before.

An automation now stays on the device that fired it: set_variable targets it
and condition reads come from its state, so a rule about one greenhouse can't
switch the fan in another.

Pending control was returned in full to any polling board, so a second device
executed writes meant for the first. Listing and acking are both scoped now —
a device sees broadcasts and its own, and can't consume another's queue.

Triggers name devices by their D1 id while storage calls the default device
'', so the id is mapped before comparing. Without that a trigger pinned to
the default device matches nothing and the automation silently never fires.
Drives esptool-js through the port handoff the console already owns, so the
monitor tears down cleanly and comes back when the write finishes.

Flasher progress is pushed into the same console buffer. It arrives out of
band — the port is speaking binary to the ROM loader at the time — and
without it the console simply goes dead for twenty seconds mid-session.

after() resets the board once writing completes; skipping it leaves the chip
in the ROM bootloader until it is physically unplugged, which reads as a
bricked board. The transport is disconnected in a finally so a failed flash
doesn't leave the port held for the rest of the session.

esptool-js is 106 kB, so it stays in the lazily loaded Flash chunk rather
than the main bundle.
GitHub's API sends CORS headers but release assets do not — they redirect to
a host that sends none — so the browser can read the catalogue and cannot
read the binary. The worker proxies both, which also keeps every browser off
the unauthenticated GitHub rate limit.

The download endpoint takes a tag and a filename, never a URL, and builds the
target from a fixed repo. Both parts are charset-checked and '..' is rejected
on its own, since the charset allows dots and v1..2 would otherwise climb out
of the release path.

The catalogue caches in KV behind an ETag. No published release reads as an
empty catalogue rather than an error, and the panel falls back to picking a
local .bin, which is what it did before.
Assigning firmware to a device sets desired state and returns. The board
compares its reported version against that and pulls when they differ —
nothing is pushed at it, no job is tracked, and the slow part runs on the
ESP32 where a Worker CPU limit can't reach it.

Devices get two endpoints, both on the token and device header they already
use for telemetry: one asks whether to update, one streams the image from R2.

Success is the board reporting the desired version on its next hello. That's
the only honest signal available — the cloud cannot know a device booted.

A failed insert deletes the image it already wrote, so losing the unique
version race can't strand an object in R2 that nothing points at.

The nudge crosses into Durable Object storage, where the default device is
'' rather than its D1 id, so it goes through storageIdOf. That mapping has
now caught three call sites and lives in one place.
The devices table reads "Running" and "Should run" rather than offering a
start button, because that is what the system does — you state desired state
and the board reconciles when it next checks. There is no job to watch.

The upload form warns that the version must match what the sketch reports.
A mismatch updates the board and never marks it done, which is the easiest
way to be confused by any of this.

Images post as a raw body rather than through the JSON helper; base64 in JSON
would inflate a megabyte image by a third for nothing.
A CodeMirror editor with C++ highlighting, seeded from the SDK examples and
kept per project in localStorage.

Example sources are read at the same release tag the binaries were built
from, not the default branch, so what's on screen is what a published image
was compiled from. raw.githubusercontent sends CORS headers, so unlike
release assets this needs no proxy.

The compile box explains why a browser can't run a C++ toolchain and what to
do instead, rather than offering a button that does nothing.

CodeMirror and esptool-js are both excluded from the service worker
precache. Workbox globs every chunk, so ~630 kB of toolchain was being
downloaded on install by people who may never open either tab.
Deleting a variable cleared its hot state for every device in the project,
not the one that owned it. Five sensors reporting temp from five places meant
removing one wiped all five — the exact case device scoping exists for.

Deleting a project wiped telemetry/ but not firmware/, leaving images in R2
that nothing could reach and nothing would ever remove.
The migration to 2.0 runs once, unattended, on instances nobody can observe.
Every other safeguard — batched statements, transaction-wrapped Durable
Object steps — lowers the chance of failure without putting a floor under it.
This is the floor: a copy taken before upgrading, and something to compare
against afterwards.

NDJSON, one typed record per line, streamed from an async generator so a
project carrying a year of telemetry never sits in memory.

No secrets leave: token hashes, sealed integration config and dashboard share
tokens are all omitted. That makes the file safe to hand to someone, and
means it restores data rather than credentials.
The image endpoint serves about a megabyte a call and had no throttle. A
boot-looping board — likelier than an attacker — would pull it in a loop
against R2 egress.

The counter lives in Durable Object SQLite rather than KV. The existing auth
throttle is deliberately soft and fails open, which is right when the cost of
over-blocking is locking someone out of their own instance. Here the cost is
money, and a device hammering in a loop can outrun an eventually consistent
counter, so this one is strongly consistent and fails closed.
Durable Objects have no migration runner, so an object created today and one
that predates devices arrive by different routes. The divergence that matters
is silent — a column NOT NULL on one and nullable on the other — so the two
are now compared directly rather than assumed equal.

Also asserts what the '' sentinel exists for: two writes to the default
device produce one row. With NULL they would produce two, because SQLite
treats NULLs as distinct in a unique key.

The schema ladder moves to its own module so a test can drive it without
pulling in the Durable Object runtime.
The ring buffer capped 1,000 rows for the whole project. That is generous
with one board and quietly wrong with five — 200 points each, charts thinning
as hardware is added, and nothing anywhere explaining why.
Derived from last_seen at read time rather than stored. A device that goes
quiet never writes anything, so there is nothing to flip a stored flag.
A key stopped being unique once variables became device-scoped. set_variable
checked existence with project and key alone, so an LLM could target one
device using a variable that only exists on another.

Reading with no device still spans the project, which is what a single-device
instance always returned. Writing with no device targets the default rather
than broadcasting — a model steering hardware should reach one board unless
it says otherwise.
Every upload was an artifact nothing would ever remove — around 1.5 MB each
against a 10 GB free tier, unbounded.

Keeps the ten most recent, plus anything a device runs or is waiting to run.
Those exclusions are the point: dropping a desired image strands a pending
update behind a dangling reference, and dropping a running version loses the
image a board in the field is actually on.

Pruning can't fail an upload that already succeeded, so a failure leaves one
extra image behind until the next one.
Dashboard widgets bind to a bare variable key, but storage is per device now.
A second board reporting the same key made getLatestState return both rows —
the widget showing whichever came last — and merged both into one chart
series. Silently, with no error and no way to tell.

The automation engine had the same shape for schedule, sunset and manual
runs, which carry no device. That one was accidentally right, since ''
sorts before any generated id; it no longer depends on collation.

/state still returns every device grouped — showing all of them is its job.
Widgets bind to a bare variable key, so a dashboard needs to say which board
those keys belong to. Layout gains an optional device; absent means the
project's default, which is what every dashboard did before devices existed —
so no stored layout changes and nothing needs migrating.

Dashboard-level rather than per-widget. Per-widget would touch every widget's
props, the extractor, the builder and the snapshot API to fetch several
devices at once; this composes with that later rather than blocking it. The
cost today is that five sensors in five places means five dashboards.
The selector only appears once a project has more than one device, so a
single-board instance gains no control it has no use for.

normalizeLayout rebuilds the layout field by field and was dropping device
when rescaling an older grid, which would have quietly reset a dashboard to
the default device on the next save.
The agent dials into ProjectDO and its socket is tagged in the attachment, so
agent frames and device frames route apart on the same object — no new
Durable Object class.

Nothing is queued for an absent agent. Compiling is work on someone's
physical machine, and a queue would fire builds hours later when a laptop
reopens; no agent connected is an immediate 409.

The browser's request is held open until the agent answers, which works
because a Worker has no wall-clock limit while a client is connected. Five
minutes, since a first build installs a toolchain.

Owner and admin at both ends, on the socket and on the build request. A
member triggering a build would be arbitrary toolchain execution on someone
else's laptop.
recordDeviceSeen had one caller — the WebSocket hello handler — so HTTP-mode
boards never updated last_seen and the online indicator read them as never
seen. On an upgraded instance every device is the default device, so that is
every device.

touchDevice writes at most once a minute per device and is called from HTTP
ingest, the control poll, the OTA check and image download, and WS telemetry
(hello alone left a long-lived socket looking stale).

The WS path also attributed variables to the default device regardless of
which device the socket belonged to, so a named board's telemetry landed
under its own id while its variable rows were created elsewhere.
requestBuild carried the whole binary back as base64 through an object that
is also running ingest, the ring buffer, R2 flushes and automation
evaluation for every board in the project.

The DO now carries job control only. The agent PUTs to /v1/agent/artifact
which streams straight into R2, then reports the result, so ok always means
the object is collectable; the browser fetches it once and the route deletes
it. builds/ joins the project-delete prefix loop, and a sweep on each new
build drops anything a timed-out request left behind.
… shipped

An HTTP-mode board also never reported the version it runs, so offerFor
compared against NULL, offered the update, and would have gone on offering
it after it succeeded — boot, flash, restart, repeat, bounded only by the
hourly download quota.

The check route now reads X-Nodrix-Firmware and X-Nodrix-Chip and feeds them
to the same recordDeviceSeen and reconcile pair the hello frame uses. offerFor
takes the reported version and prefers it over the stored one, so the answer
is right in the same request instead of one round trip later.
The device plane: author, build, flash, provision, observe, update
requestBuild buffered every log line and handed them over at the end, so a
cold toolchain install was minutes of a spinner and then a wall of text —
which reads as a hang, and is exactly what makes people retry.

It now returns NDJSON as the agent emits it: log frames, then one result
frame carrying the outcome. RPC only carries byte-oriented streams, so the
readable is built with type: 'bytes'; a plain TransformStream typechecks and
fails at runtime. The route is a 200 either way now, since the outcome is in
the body rather than the status.
It offered three things. Flashing a prebuilt SDK example is now "load the
example into the editor and press Flash". Flashing a .bin you already have
is not something the platform needs to own. A hand-entered flash offset is
guesswork the build itself knows.

That takes the release-asset proxy with it — binaryUrl, fetchBinary, the two
regexes standing between it and an open redirect, and the test guarding them.
The catalogue stays: the editor's example picker reads it.

The build artifact also stops being deleted on read, so one build can be
flashed over USB and kept for OTA rather than one or the other. The hourly
sweep already collects whatever nobody promotes.
The upload form asked for a .bin and a version string, and warned in its own
help text that the version "must match what the sketch reports, or the device
will never be marked updated". That mismatch was possible only because
uploading was divorced from building.

A build now becomes a firmware version by being kept: publishBuild copies the
artifact out of builds/ and gives it a row. The version is the build id, which
the agent stamps into the sketch at compile time, so what the board reports and
what the row says are the same string by construction.

Code offers both destinations for one compile — flash over USB, or save for
OTA — and reuses the artifact rather than rebuilding. Assignment moves onto the
Devices table, so the firmware page and its endpoint both go.
The offset was hardcoded to 0x10000, which is where an ESP32 app sits above
its bootloader and partition table. An ESP8266 sketch is the whole image and
starts at zero, so picking NodeMCU wrote 64 KB past the start and left a board
that could not boot. The offset now comes from the selected board.
Writing a sketch, watching it build, and watching the board boot were three
tabs, so the two halves of one loop never shared a screen.

Editor on top, a dock underneath with Console and Serial monitor. The dock
follows the build, then switches to the monitor once a flash lands, which is
the moment the board starts printing. Flash progress rides in the tab strip
rather than taking a panel of its own.

CodeMirror grows with its content by default, which would push the dock off
screen on a long sketch, so the editor now fills the pane and scrolls itself.
SerialConsole moves to components/SerialMonitor and fills the height it is
given instead of naming its own.

Device tabs are down to Devices and Code. The FlashPanel precache ignore goes
with them — it stopped matching anything when that page was deleted.
One page to write, build, flash and watch a board
… picker

Five native selects were left behind while the rest of the app moved to
Dropdown: board and example on the code page, baud on the serial monitor,
which build a device should run, and which device a dashboard reads. Empty
options become the placeholder, which is what Dropdown already models, and
the two that used :value with a change handler keep that shape.

The example picker was worse than redundant, it was inert. It listed assets
named <Example>-<target>.bin on the SDK's latest release, and the only
published release carries no assets at all, so the list was always empty and
Load was always disabled.

Removing it takes the whole catalogue: the GitHub releases fetch, its ETag
handling and KV cache, the /v1/admin/firmware mount, and both catalogue types.
The code page now talks only to its own instance. The starter sketch still
seeds an empty editor.
Custom dropdowns everywhere, and one dead feature removed
Pressing Flash without one running printed "no agent is connected" into the
console and stopped there. Nothing said an agent is a thing you download and
run on your own machine, and the only instructions were in another repo's
README, which nobody had a reason to find.

The failure now carries a code rather than a sentence to match on, and the
console answers it: what the agent is for, the download named for the
platform the browser is on, the instance URL already filled in, and the
arduino-cli core the build will need next.

The main README gained the same path, since deploying an instance was
documented but flashing a board from it was not.
Tell people what an agent is when a build needs one
Copilot AI lite review requested due to automatic review settings September 16, 2026 10:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved blocking issues remain across flashing, device scoping, concurrency, OTA compatibility, export privacy, and migration handling.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds device-aware telemetry, dashboards, automations, firmware OTA, browser-based editing/flashing, and Wrangler configuration synchronization.

Changes:

  • Adds device APIs, storage, controls, migrations, and automation targeting.
  • Adds local-agent builds, Web Serial flashing, and OTA management.
  • Adds Wrangler merge tooling, tests, and deployment documentation.
File summaries
File Summary
wrangler.toml Updates deployment-sync comments.
worker/test/ws-protocol.test.ts Tests device WebSocket messages.
worker/test/ota-offer.test.ts Tests OTA offer reconciliation.
worker/test/migrations.test.ts Tests D1 migrations.
worker/test/do-schema.test.ts Tests Durable Object schema upgrades.
worker/test/device-seen.test.ts Tests device heartbeat throttling.
worker/src/routes.ts Registers device and firmware routes.
worker/src/platform/lib/layout.ts Validates dashboard device selection.
worker/src/platform/lib/ids.ts Adds device, firmware, and build IDs.
worker/src/platform/lib/audit.ts Adds device and firmware audit targets.
worker/src/platform/engine/types.ts Defines device-aware automation context.
worker/src/platform/engine/run.ts Runs device-aware automations.
worker/src/platform/durable-objects/schema.ts Adds DO schema migration handling.
worker/src/platform/durable-objects/project-schema.ts Adds device-scoped DO tables.
worker/src/platform/durable-objects/project-do.ts Handles device state, telemetry, controls, and OTA.
worker/src/platform/durable-objects/dashboard-do.ts Provides device-scoped dashboard snapshots.
worker/src/platform/db/migrations/0002_devices.sql Adds device and firmware tables.
worker/src/platform/db/migrations.gen.ts Updates generated migrations.
worker/src/platform/db/auto-migrate.ts Executes runtime migrations.
worker/src/mcp/tools-write.ts Adds device-targeted MCP writes.
worker/src/mcp/tools-read.ts Adds device-aware MCP reads.
worker/src/domains/variables/service.ts Groups state and controls by device.
worker/src/domains/variables/routes.ts Handles device-scoped variables.
worker/src/domains/telemetry/ws-protocol.ts Extends the device WebSocket protocol.
worker/src/domains/telemetry/variables.ts Adds device variable upserts.
worker/src/domains/telemetry/telemetry.ts Routes telemetry to devices.
worker/src/domains/telemetry/control.ts Routes controls to devices.
worker/src/domains/projects/service.ts Creates default project devices.
worker/src/domains/projects/routes.ts Adds project export handling.
worker/src/domains/projects/export.ts Exports project and device data.
worker/src/domains/firmware/ota.ts Implements firmware publishing and OTA offers.
worker/src/domains/firmware/device.ts Serves device OTA endpoints.
worker/src/domains/firmware/agent.ts Handles agent builds and artifacts.
worker/src/domains/firmware/admin.ts Adds firmware administration routes.
worker/src/domains/devices/service.ts Manages device lifecycle and identity.
worker/src/domains/devices/routes.ts Adds device management APIs.
web/vite.config.ts Configures PWA and bundles.
web/tsconfig.json Adds Web Serial typings.
web/test/serial-diagnosis.test.ts Tests serial diagnostics.
web/src/types.ts Adds device and firmware types.
web/src/stores/project.ts Loads and manages devices and firmware.
web/src/router.ts Adds device routes.
web/src/pages/Projects.vue Adds project export access.
web/src/pages/project/device/DevicesList.vue Manages devices and OTA assignments.
web/src/pages/project/device/DeviceHub.vue Adds device navigation.
web/src/pages/project/device/CodePanel.vue Provides browser code, build, and flash workflows.
web/src/pages/project/DashboardEdit.vue Adds dashboard device selection.
web/src/pages/project/automations/NodeInspector.vue Adds trigger device selection.
web/src/pages/project/automations/AutomationEditor.vue Loads project devices for automations.
web/src/layouts/Sidebar.vue Adds device navigation.
web/src/composables/useSerialPort.ts Manages Web Serial connections.
web/src/composables/useSerialLog.ts Buffers and classifies serial logs.
web/src/composables/useSerialDiagnosis.ts Diagnoses serial errors.
web/src/composables/useEspFlasher.ts Implements ESP flashing.
web/src/composables/useAgentBuild.ts Streams local-agent builds.
web/src/components/SerialMonitor.vue Adds the serial monitor UI.
web/src/components/CodeEditor.vue Adds the CodeMirror editor.
web/src/builder/grid.ts Preserves device layout metadata.
web/src/api.ts Adds binary response support.
web/package.json Adds editor, serial, and flashing dependencies.
shared/blocks/triggers.ts Adds device trigger fields.
shared/blocks/index.ts Extends shared block field types.
scripts/merge-wrangler.ts Merges deployment and upstream configuration.
scripts/merge-wrangler.test.ts Tests Wrangler merging.
scripts/build-from-upstream.sh Integrates configuration merging.
README.md Documents the agent and device workflow.
deploy/wrangler.toml Updates the deployment template.
Review details

Suppressed comments (19)

README.md:50

  • This copy-paste command hardcodes the ARM64 macOS asset even though the release also provides an x64 macOS build. Intel Mac users following the README will download an incompatible binary; provide architecture-specific commands or direct them to an explicit platform selection.
```bash
curl -fsSL -o nodrix-agent \
  https://github.com/decoded-cipher/nodrix-agent/releases/latest/download/nodrix-agent-macos-arm64
chmod +x nodrix-agent
arduino-cli core install esp32:esp32

NODRIX_INSTANCE=https://<your-worker> NODRIX_TOKEN=<admin token> ./nodrix-agent

README.md:50

  • The README command also omits NODRIX_PROJECT. With an all-project admin token, the agent's WebSocket and artifact requests are rejected as project required, so these documented setup steps do not work unless the user happens to create a project-scoped token. Include the project ID in the command or document the scoped-token requirement.
NODRIX_INSTANCE=https://<your-worker> NODRIX_TOKEN=<admin token> ./nodrix-agent

README.md:59

  • The architecture bullet claims the Worker has a Durable Object for “the MCP agent,” but the added build agent is a separate CLI and MCP is exposed by the Hono /v1/mcp endpoint; there is no MCP Durable Object. This makes the new architecture documentation misleading—list the actual Worker DOs and describe the CLI agent separately.
- **Worker** ([worker/](worker/)) — a single Hono app. Durable Objects for Project, Dashboard, Scheduler, and the MCP agent; one Workflow for provisioning; D1 (metadata), R2 (telemetry history), KV (read cache + JWKS).

web/src/pages/project/device/CodePanel.vue:65

  • When the selected binary is the Windows .exe, this generated setup still uses Unix-only chmod and ./nodrix-agent commands and saves the file without an .exe name. A Windows user cannot follow the displayed setup to start the agent; provide platform-specific PowerShell/Command Prompt instructions and an executable filename.
  `curl -fsSL -o nodrix-agent ${AGENT_RELEASES}/download/${agentBinary.value}`,
  'chmod +x nodrix-agent',
  '',
  `NODRIX_INSTANCE=${window.location.origin} \\`,

web/src/pages/project/device/CodePanel.vue:118

  • requestPort() is invoked only after await build(). Since the build waits on a local agent and can take minutes, the click's transient user activation will have expired and Web Serial will reject the request before the first flash. Request the port before starting the asynchronous build (or require a separate user-initiated connect action).
async function compileAndFlash() {
  const id = await build();
  if (!id) return;
  try {
    if (!port.value && !(await request())) return;
    const pid = project.currentProjectId ?? '';
    const bytes = new Uint8Array(await api.bytes(`/v1/admin/projects/${pid}/build/${id}/artifact`));

web/src/pages/project/device/CodePanel.vue:67

  • When the admin token is not project-scoped, authoriseAgent requires a project query parameter, but these copied commands set only the instance and token. The agent supports NODRIX_PROJECT for this case, so the default all-project-token flow cannot connect; include the current project ID or otherwise create a project-scoped token.
  `NODRIX_INSTANCE=${window.location.origin} \\`,
  'NODRIX_TOKEN=<admin token from Account -> Tokens> \\',
  './nodrix-agent',

web/src/pages/project/device/CodePanel.vue:148

  • storageKey reacts to project changes, but the draft is only loaded in onMounted; Vue Router reuses this component when only :proj changes. Navigating from project A to B while staying on /device/code therefore leaves A's sketch in the editor, and the next edit writes it to B's localStorage key. Reload the draft whenever the project/storage key changes.
const storageKey = computed(() => `nodrix:sketch:${project.currentProjectId ?? 'none'}`);

onMounted(() => {
  code.value = localStorage.getItem(storageKey.value) ?? STARTER;
});

watch(code, (v) => localStorage.setItem(storageKey.value, v));

worker/src/domains/devices/service.ts:95

  • When the device cap is reached, a new named device is returned with the project's default storage id. Its telemetry and OTA requests then silently mix into the default device, and a WebSocket that sends this key remains attached as default. Reject or isolate the overflow device instead of making it impersonate the default.
    worker/src/domains/devices/service.ts:105
  • The inserted display name is the raw deviceKey, while listDevices exposes name to the UI. MAC-like keys will therefore appear in user-readable device lists, contradicting the migration's privacy contract that the key never leaks; use a neutral initial name and keep the key internal.
    worker/src/domains/firmware/agent.ts:27
  • This cleanup lists only the first R2 page and never follows truncated, so projects with more than one page of build artifacts retain stale objects indefinitely. Iterate with the R2 cursor just as project destruction does.
    worker/src/domains/telemetry/variables.ts:70
  • MAX_VARIABLES_PER_PROJECT is enforced by a separate count query and a later batch insert. Concurrent telemetry from different devices can both observe the remaining capacity and insert beyond 250, so the resource-growth cap is not reliable. Make the capacity reservation and insert atomic or enforce it with a database-level mechanism.
    worker/src/domains/variables/service.ts:10
  • The database now allows the same key once per device, but VariableSummary omits device_id. listVariables() can therefore return indistinguishable duplicate entries, while the existing UI and automation selectors use only key, making it impossible to choose the intended device-scoped variable. Include device identity in the API model and selectors.
    worker/src/platform/db/auto-migrate.ts:55
  • When another isolate has already inserted the (name, applied_at=0) claim, this branch just skips the migration and returns. That request can immediately use tables/columns that the claimant has not created yet, and it also returns successfully if the claimant later fails and deletes the claim. Wait for the claim to become applied or retry after it is released before allowing the request through.
    worker/src/platform/durable-objects/dashboard-do.ts:185
  • This resolves layout.device for the authenticated WebSocket bootstrap, but the public polling endpoint still calls getDashboardSnapshot without the selected storage ID and therefore falls back to the default device. A non-default dashboard is correct in the member view but shows the default device when shared; apply the same device resolution in the public path.
    worker/src/platform/durable-objects/dashboard-do.ts:202
  • The initial snapshot is device-scoped here, but the Project DO subscription still sends batches without a device identity and every socket applies them. Once this dashboard selects a device, telemetry from another board can overwrite its live widgets; carry the device through notifications or filter the socket by the selected device.
    worker/src/platform/durable-objects/project-do.ts:372
  • When deviceId is omitted, the new MCP contract says this reads across every device, but the query and SeriesRow omit device_id. Same-key points from different boards are therefore merged without any way for callers to attribute them; return the device id or require an explicit device for this result.
    worker/src/platform/durable-objects/project-do.ts:304
  • This default makes unchanged callers such as the public dashboard always read the default device, even when the dashboard layout now selects another device. Resolve layout.device and pass its storage id at every snapshot caller (or make the device argument required) so public snapshots honor the same selection as authenticated dashboards.
    worker/src/platform/durable-objects/project-do.ts:638
  • resolveDevice deliberately falls back to { id: default, storageId: '' } when the device cap is reached, but this check rejects every empty storage ID. A named board hitting the cap therefore stays attached as the pre-hello default and never gets its hello metadata or device-specific pending-control handoff. Check only !device and allow '' as the valid default storage ID.
    worker/src/platform/engine/run.ts:213
  • A variable-trigger run supplies ctx.device and custom device-scoped dependencies, but a delay resume calls runAutomation without those dependencies. The default reader below hardcodes the default storage ID (and the default writer does the same), so a delayed condition/action resumes against the default device instead of the triggering device. Recreate device-aware dependencies from the persisted context on resume.
  • Files reviewed: 67/67 changed files
  • Comments generated: 15
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +118 to +120
const bytes = new Uint8Array(await api.bytes(`/v1/admin/projects/${pid}/build/${id}/artifact`));
if (await flash([{ data: bytes, address: flashOffset.value }])) {
toast.success('Flashed — the board is restarting');
Comment on lines +89 to +93
const count = await env.DB
.prepare(`SELECT COUNT(*) AS n FROM devices WHERE project_id = ?`)
.bind(projectId)
.first<{ n: number }>();
if ((count?.n ?? 0) >= MAX_DEVICES_PER_PROJECT) {
Comment on lines +67 to +72
const len = Number(c.req.header('content-length') ?? '0');
if (!Number.isFinite(len) || len <= 0) return c.text('length required', 411);
if (len > MAX_ARTIFACT_BYTES) return c.text('artifact too large', 413);
if (!c.req.raw.body) return c.text('empty body', 400);

await c.env.R2.put(artifactKey(projectId, build), c.req.raw.body, {
Comment on lines +53 to +56
`INSERT INTO firmware (id, project_id, version, target, size, sha256, r2_key, notes, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, projectId, buildId, null, body.byteLength, sha256, key, notes, userId, now)
Comment on lines +23 to +24
['device', `SELECT id, name, device_key, chip, firmware_version, is_default, first_seen, last_seen, created_at
FROM devices WHERE project_id = ?`],
// Empty means the default device, so a dashboard made before devices existed
// keeps reading what it always did.
function setDevice(id: string) {
layout.value = { ...layout.value, device: id || null };
Comment on lines +53 to +57
// Apple silicon and Intel are indistinguishable from the user agent.
const agentBinary = computed(() => {
const ua = navigator.userAgent;
if (ua.includes('Win')) return 'nodrix-agent-windows-x64.exe';
if (ua.includes('Mac')) return 'nodrix-agent-macos-arm64';
Comment on lines +49 to +55
if (!(await projectStub(c.env, project_id).consumeOtaQuota(storageId))) {
return c.json({ error: 'too_many_requests' }, 429, { 'retry-after': '3600' });
}

const object = await openImage(c.env, project_id, deviceId);
if (!object) return c.json({ error: 'not_found' }, 404);
return new Response(object.body, {
Comment on lines +31 to +35
export type DeviceState = { id: string; name: string; variables: Record<string, StateEntry> };

// Latest value of every variable. Mirrors GET /v1/projects/:proj/state.
export async function getState(env: Env, projectId: string): Promise<Record<string, StateEntry>> {
const latest = await projectStub(env, projectId).getLatestState();
const out: Record<string, StateEntry> = {};
for (const r of latest) out[r.variable] = { value: r.value, received_at: r.received_at };
return out;
// Mirrors GET /v1/projects/:proj/state. Grouped by device because two of them
// may report the same key, and a flat map would drop one.
export async function getState(env: Env, projectId: string): Promise<DeviceState[]> {
const series = this.getSeriesForVariables(variables, sinceTs, cap);
const latest = this.getLatestState(deviceId);
const series = this.getSeriesForVariables(variables, sinceTs, cap, deviceId);
return { latest: await latest, series: await series, oldestTs: this.ringOldestTs() };
@decoded-cipher

Copy link
Copy Markdown
Owner Author

Superseded by the OTA branch, which builds on this work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants