Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
48f5051
fix(migrate): apply each D1 migration as a single transaction
decoded-cipher Aug 22, 2026
f55debc
feat(do): version Durable Object schemas and migrate them atomically
decoded-cipher Aug 22, 2026
d2cc81b
feat(deploy): rebuild wrangler.toml from upstream on every deploy
decoded-cipher Aug 22, 2026
00b7ee0
feat(web): add a single-owner Web Serial port composable
decoded-cipher Aug 22, 2026
daed5a6
feat(web): classify and buffer serial console output
decoded-cipher Aug 22, 2026
cb28033
feat(web): add the serial console
decoded-cipher Aug 22, 2026
d49d63d
feat(web): explain what the board is doing in plain language
decoded-cipher Aug 22, 2026
65c0cae
feat(db): give every project devices and scope variables to them
decoded-cipher Aug 22, 2026
9846b93
feat(worker): attribute incoming telemetry to a device
decoded-cipher Aug 22, 2026
b1e458d
feat(do): key project storage by device
decoded-cipher Aug 22, 2026
54c5073
feat(worker): make devices addressable end to end
decoded-cipher Aug 22, 2026
45585f6
feat(worker): let WebSocket boards identify themselves
decoded-cipher Aug 22, 2026
317c912
feat(web): list, rename and forget devices
decoded-cipher Aug 22, 2026
246264b
feat(worker): scope automations and control delivery to a device
decoded-cipher Aug 22, 2026
fc63db9
feat(web): flash a board from the browser
decoded-cipher Aug 22, 2026
9c76c95
feat(worker): flash published SDK examples through the browser
decoded-cipher Aug 22, 2026
58a719e
feat(worker): over-the-air updates by reconciliation
decoded-cipher Aug 22, 2026
3ba742d
feat(web): upload firmware and choose what each device should run
decoded-cipher Aug 22, 2026
1063926
feat(web): edit sketches in the browser
decoded-cipher Aug 22, 2026
cb84f38
fix(worker): stop device work leaking data and objects
decoded-cipher Aug 22, 2026
fb52966
feat(worker): export a project's data
decoded-cipher Aug 22, 2026
0f32ebd
feat(worker): cap how often a device can pull firmware
decoded-cipher Aug 22, 2026
dd0540d
test(do): prove fresh and migrated objects reach the same schema
decoded-cipher Aug 22, 2026
4d9527c
fix(do): budget recent history per device
decoded-cipher Aug 22, 2026
3360e28
feat(web): show whether a device is still reporting
decoded-cipher Aug 22, 2026
c624731
fix(mcp): address variables by device, not by bare key
decoded-cipher Aug 22, 2026
5982605
feat(worker): prune old firmware images
decoded-cipher Aug 22, 2026
81e1d40
fix(do): pin device-blind reads to the default device
decoded-cipher Aug 22, 2026
59c3c6a
feat(dashboards): let a dashboard name its device
decoded-cipher Aug 22, 2026
1e95c71
feat(web): pick which device a dashboard reads
decoded-cipher Aug 22, 2026
68a00ba
feat(worker): compile a sketch through the agent
decoded-cipher Aug 22, 2026
d7923f7
fix(devices): keep last_seen current on every device-facing path
decoded-cipher Aug 22, 2026
e381d5d
fix(build): keep compiled artifacts out of the project DO
decoded-cipher Aug 22, 2026
3e01297
fix(ota): reconcile with boards that were not listening when firmware…
decoded-cipher Aug 22, 2026
cfd7abb
Merge pull request #5 from decoded-cipher/feat/device-plane
decoded-cipher Aug 22, 2026
babe2f7
feat(build): stream the toolchain output while the build runs
decoded-cipher Aug 22, 2026
72cc3f1
refactor(device): drop the separate flash page
decoded-cipher Aug 22, 2026
bd529ef
refactor(firmware): make a saved build the only kind of firmware
decoded-cipher Aug 22, 2026
c1c2c41
fix(web): flash an ESP8266 at the address its image actually starts
decoded-cipher Aug 22, 2026
1adc3a9
feat(web): make the code page work like the Arduino IDE
decoded-cipher Aug 22, 2026
ca2c87d
Merge pull request #6 from decoded-cipher/refactor/device-simplification
decoded-cipher Aug 22, 2026
b93e26b
refactor(web): custom dropdowns everywhere, and drop the dead example…
decoded-cipher Aug 22, 2026
23dfa8b
Merge pull request #7 from decoded-cipher/refactor/device-simplification
decoded-cipher Aug 22, 2026
e9b69f4
feat(web): tell people what an agent is when a build needs one
decoded-cipher Aug 22, 2026
d253d7c
Merge pull request #8 from decoded-cipher/feat/agent-discoverability
decoded-cipher Aug 22, 2026
c7d759d
refactor(firmware): drop the agent build path
decoded-cipher Sep 17, 2026
a417a87
refactor(web): remove the code editor, flasher and serial monitor
decoded-cipher Sep 17, 2026
882df22
feat(firmware): publish a firmware version by uploading its image
decoded-cipher Sep 17, 2026
4554748
fix(ota): stop offering an update the board never applies
decoded-cipher Sep 17, 2026
00047eb
feat(web): upload firmware and see whether a board took it
decoded-cipher Sep 17, 2026
a5dee78
docs: describe shipping firmware over the air
decoded-cipher Sep 17, 2026
86e4b89
chore: bump version to 1.1.0
decoded-cipher Sep 17, 2026
d5e3e85
Merge branch 'master' into feat/firmware-upload
decoded-cipher Sep 18, 2026
60ff39f
feat(web): upload firmware from the Devices page
decoded-cipher Sep 18, 2026
28414fc
docs(readme): point at the firmware tab and name the library version
decoded-cipher Sep 18, 2026
8e8bae8
feat(web): icon actions on the devices table
decoded-cipher Sep 18, 2026
1628cb6
fix(web): dress the upload dialog in the app's own design language
decoded-cipher Sep 18, 2026
27a12f2
fix(web): load devices on a cold page load, and pick files with a button
decoded-cipher Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 🧠 **Native MCP server** — read telemetry and control hardware from an AI assistant, on devices you are not physically next to. Owner-gated and off by default; see [AI assistants (MCP)](#ai-assistants-mcp).
- 👥 **Multi-user** — owner / admin / member roles, email invites, and social sign-in (Google, GitHub).
- 📝 **Audit log** — every privileged action recorded and paginated in the UI.
- 🔧 **Over-the-air updates** — compile with your own toolchain, upload the image, assign it to a board; it pulls the update on its next check.

## Quick start

Expand All @@ -36,6 +37,18 @@
-H "Authorization: Bearer $NODRIX_TOKEN"
```

## Updating hardware over the air

Compile with the toolchain you already use, upload the image, and any board in the project can take it on its next check — no USB, no cable, no physical access.

1. **Version the sketch.** `Nodrix.setFirmwareVersion("1.2.0")` — the board reports this back, and it is how nodrix knows the update landed. Needs the [Nodrix library](https://github.com/decoded-cipher/nodrix-sdk) at 0.2.0 or newer.
2. **Compile.** Arduino IDE: **Sketch → Export Compiled Binary**, then take `<sketch>.ino.bin` from `build/<board>/`. `arduino-cli compile --output-dir build` and PlatformIO's `.pio/build/<env>/firmware.bin` work the same way. Upload the app image, not `.ino.merged.bin` or `.ino.bootloader.bin` — nodrix rejects those, since a board flashed with one over the air would not boot.
3. **Upload** it on **Devices → Firmware** with the same version string, then **assign** it to a board on **Devices**.

A board on the control socket is told immediately; one on HTTP finds it at its next check or on reboot. The board reports the new version when it comes back up, which is what marks the update done. If it keeps pulling the image without ever reporting the new version — nearly always the version in the sketch not matching the one on the upload — nodrix stops offering it after a few attempts and says so, instead of leaving the board reinstalling forever. Assigning the firmware again clears that and retries.

The first flash of a board is still over USB, with the Arduino IDE or `esptool`, and it needs a partition scheme with OTA slots (the ESP32 default has them). Every image you ship over the air has to include the nodrix SDK, or the board will have no way to receive the next one.

## AI assistants (MCP)

nodrix ships a native [Model Context Protocol](https://modelcontextprotocol.io) server, so an assistant can read a project's telemetry and — once you allow it — act on the hardware. Because devices report to your deployment rather than to your laptop, the board does not need to be plugged into the machine running the assistant; it can be in another building.
Expand Down
9 changes: 5 additions & 4 deletions deploy/wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# nodrix deploy carrier. The Deploy to Cloudflare button clones only this
# subdir; the build command pulls the real source from upstream over it. Don't
# edit by hand — manage the deployment from the Cloudflare dashboard. Keep the
# bindings in sync with the repo-root wrangler.toml.
# nodrix deploy carrier. The Deploy to Cloudflare button clones only this subdir;
# the build command pulls the real source from upstream over it and rebuilds the
# deployment's wrangler.toml from this file, keeping only its Worker name,
# account, routes, resource IDs and vars. Everything else here reaches every
# existing deployment on its next build — keep it in sync with the root config.
name = "nodrix"
main = "worker/src/index.ts"
compatibility_date = "2025-05-01"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nodrix",
"private": true,
"version": "1.0.1",
"version": "1.1.0",
"type": "module",
"workspaces": [
"worker",
Expand Down
24 changes: 18 additions & 6 deletions scripts/build-from-upstream.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
# source of truth — meaning code changes in upstream never reach them. With
# this script, every deploy:
#
# 1. Preserves the user's wrangler.toml (which has their resource IDs,
# filled by the Deploy button on day 1 and never changed since).
# 2. Replaces every other file with the upstream source's contents.
# 1. Replaces every file with the upstream source's contents.
# 2. Rebuilds wrangler.toml from upstream's carrier template, keeping only the
# deployment's identity (Worker name, account, routes, resource IDs, vars).
# Keeping the whole file instead froze the topology at day 1, so a binding
# or flag added upstream never reached anyone who had already deployed.
# 3. Runs upstream's build pipeline.
#
# Result: the user's clone is functionally a config carrier. Code = upstream.
Expand All @@ -31,6 +33,7 @@ UPSTREAM_REPO="${NODRIX_UPSTREAM_REPO:-decoded-cipher/nodrix}"
DEPLOY_CHANNEL="${NODRIX_DEPLOY_CHANNEL:-release}"
UPSTREAM_DIR="/tmp/nodrix-upstream"
WRANGLER_BACKUP="/tmp/nodrix-wrangler.toml"
WRANGLER_MERGED="/tmp/nodrix-wrangler.merged.toml"

if [ -z "${WORKERS_CI_COMMIT_SHA:-}" ]; then
echo "[build-from-upstream] not in Workers Builds CI — running local build chain"
Expand All @@ -43,7 +46,7 @@ fi

echo "[build-from-upstream] CI build — pulling upstream ${UPSTREAM_REPO} (${DEPLOY_CHANNEL} channel)"

# 1. Preserve user's wrangler.toml.
# 1. Save the deployment's wrangler.toml; step 4 merges it back.
if [ ! -f wrangler.toml ]; then
echo "[build-from-upstream] no wrangler.toml in cwd — refusing to proceed" >&2
exit 1
Expand Down Expand Up @@ -114,8 +117,17 @@ for dir in web worker scripts; do
done
done

# 4. Restore user's wrangler.toml in case upstream had its own (which it does).
cp "${WRANGLER_BACKUP}" wrangler.toml
# 4. Rebuild wrangler.toml. This script comes from master but the clone is the
# release tag, so a release predating the merge script falls back instead of
# failing.
if [ -f scripts/merge-wrangler.ts ] && [ -f ./deploy/wrangler.toml ]; then
echo "[build-from-upstream] merging deployment identity into upstream wrangler.toml"
bun scripts/merge-wrangler.ts "${WRANGLER_BACKUP}" ./deploy/wrangler.toml > "${WRANGLER_MERGED}"
mv "${WRANGLER_MERGED}" wrangler.toml
else
echo "[build-from-upstream] upstream has no merge script — keeping wrangler.toml as-is"
cp "${WRANGLER_BACKUP}" wrangler.toml
fi

# 4b. Drop the nested deploy/ that the overlay just brought in. The clone root
# IS the deploy carrier; upstream's own deploy/ dir is dead weight here and
Expand Down
116 changes: 116 additions & 0 deletions scripts/merge-wrangler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Getting this wrong on a live deployment points it at resources it doesn't own,
// or renames the Worker. Run with `bun test scripts/merge-wrangler.test.ts`.

import { test, expect } from 'bun:test';
import { mergeWrangler } from './merge-wrangler';

// Renamed, on a custom domain, tracking a fork, and predating three template changes.
const DEPLOYMENT = `name = "home-iot"
main = "worker/src/index.ts"
compatibility_date = "2025-05-01"
compatibility_flags = ["nodejs_compat"]
account_id = "acc_123"
routes = [
{ pattern = "iot.example.com", custom_domain = true }
]

[[d1_databases]]
binding = "DB"
database_name = "home-iot-db"
database_id = "aaaa-bbbb-cccc"

[[kv_namespaces]]
binding = "KV"
id = "kv_deadbeef"

[[r2_buckets]]
binding = "R2"
bucket_name = "home-iot-telemetry"

[vars]
NODRIX_UPSTREAM_REPO = "someone/nodrix-fork"
`;

const TEMPLATE = `name = "nodrix"
main = "worker/src/index.ts"
compatibility_date = "2026-01-15"
compatibility_flags = ["nodejs_compat"]

[build]
command = "curl -fsSL https://example.invalid/build.sh | bash"

[[d1_databases]]
binding = "DB"
database_name = "nodrix"
database_id = "PLACEHOLDER_FILLED_BY_DEPLOY_OR_WRANGLER"
migrations_dir = "worker/src/platform/db/migrations"

[[kv_namespaces]]
binding = "KV"
id = "PLACEHOLDER_FILLED_BY_DEPLOY_OR_WRANGLER"

[[r2_buckets]]
binding = "R2"
bucket_name = "nodrix-telemetry"

[[durable_objects.bindings]]
name = "PROJECT_DO"
class_name = "ProjectDO"

[[migrations]]
tag = "v2"
new_sqlite_classes = ["DeviceDO"]

[vars]
NODRIX_UPSTREAM_REPO = "decoded-cipher/nodrix"
NODRIX_FEATURE_FLAG = "on"

[triggers]
crons = ["0 0 * * *"]
`;

const merged = mergeWrangler(DEPLOYMENT, TEMPLATE);

test('keeps the deployment worker name', () => {
expect(merged).toContain('name = "home-iot"');
expect(merged).not.toContain('name = "nodrix"');
});

test('keeps resource ids the deployment owns', () => {
expect(merged).toContain('database_id = "aaaa-bbbb-cccc"');
expect(merged).toContain('database_name = "home-iot-db"');
expect(merged).toContain('id = "kv_deadbeef"');
expect(merged).toContain('bucket_name = "home-iot-telemetry"');
expect(merged).not.toContain('PLACEHOLDER');
});

test('keeps account and routes the template never declares', () => {
expect(merged).toContain('account_id = "acc_123"');
expect(merged).toContain('pattern = "iot.example.com"');
});

test('takes compatibility settings and build config from upstream', () => {
expect(merged).toContain('compatibility_date = "2026-01-15"');
expect(merged).toContain('https://example.invalid/build.sh');
});

test('takes new bindings, migrations and triggers from upstream', () => {
expect(merged).toContain('class_name = "ProjectDO"');
expect(merged).toContain('new_sqlite_classes = ["DeviceDO"]');
expect(merged).toContain('crons = ["0 0 * * *"]');
expect(merged).toContain('migrations_dir = "worker/src/platform/db/migrations"');
});

test('keeps an overridden var and adds one the deployment predates', () => {
expect(merged).toContain('NODRIX_UPSTREAM_REPO = "someone/nodrix-fork"');
expect(merged).toContain('NODRIX_FEATURE_FLAG = "on"');
});

test('is idempotent against its own output', () => {
expect(mergeWrangler(merged, TEMPLATE)).toBe(merged);
});

test('a fresh deployment carrying only placeholders is left as the template', () => {
const fresh = mergeWrangler(TEMPLATE, TEMPLATE);
expect(fresh).toBe(TEMPLATE);
});
171 changes: 171 additions & 0 deletions scripts/merge-wrangler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Rebuilds a deployment's wrangler.toml: bindings, flags and build config from
// upstream's carrier template, identity from the deployment's own file.
//
// Keeping the deployment's file verbatim froze its topology at whatever the
// Deploy button wrote on day one; taking upstream's verbatim would rename the
// Worker, creating a second one and orphaning the live one.

import { readFileSync } from 'node:fs';

const PRESERVED_TOP_KEYS = ['name', 'account_id', 'workers_dev', 'preview_urls', 'route', 'routes'];

const PRESERVED_RESOURCE_KEYS: Record<string, string[]> = {
d1_databases: ['database_id', 'database_name'],
kv_namespaces: ['id'],
r2_buckets: ['bucket_name'],
};

type Section = { header: string; lines: string[] };

function keyOf(line: string): string | null {
const m = /^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*=/.exec(line);
return m ? m[1]! : null;
}

// TOML arrays and inline tables can span lines; those join into one entry.
function unclosed(text: string): boolean {
let depth = 0;
let quote = '';
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === '\\') i++;
else if (c === quote) quote = '';
continue;
}
if (c === '"' || c === "'") quote = c;
else if (c === '#') break;
else if (c === '[' || c === '{') depth++;
else if (c === ']' || c === '}') depth--;
}
return depth > 0;
}

function parse(text: string): Section[] {
const sections: Section[] = [{ header: '', lines: [] }];
const raw = text.split('\n');
for (let i = 0; i < raw.length; i++) {
let line = raw[i]!;
if (line.trim().startsWith('[')) {
sections.push({ header: line.trim(), lines: [] });
continue;
}
if (keyOf(line)) {
while (unclosed(line) && i + 1 < raw.length) line += '\n' + raw[++i]!;
}
sections[sections.length - 1]!.lines.push(line);
}
return sections;
}

function valueOf(line: string): string {
const eq = line.indexOf('=');
return line.slice(eq + 1).trim().replace(/\s*#.*$/, '').replace(/^["']|["']$/g, '');
}

function lookup(section: Section, key: string): string | undefined {
for (const l of section.lines) if (keyOf(l) === key) return l;
return undefined;
}

function arrayName(header: string): string | null {
const m = /^\[\[([A-Za-z0-9_.-]+)\]\]$/.exec(header);
return m ? m[1]! : null;
}

export function mergeWrangler(sourceText: string, templateText: string): string {
const source = parse(sourceText);
const template = parse(templateText);

const sourceTop = source[0]!;
const sourceResources = new Map<string, Section>();
let sourceVars: Section | undefined;
for (const s of source) {
const name = arrayName(s.header);
if (name && name in PRESERVED_RESOURCE_KEYS) {
const binding = lookup(s, 'binding');
if (binding) sourceResources.set(`${name}:${valueOf(binding)}`, s);
} else if (s.header === '[vars]') {
sourceVars = s;
}
}

const out: string[] = [];
const usedTopKeys = new Set<string>();

for (const s of template) {
if (s.header) out.push(s.header);

const name = arrayName(s.header);
const resourceKeys = name ? PRESERVED_RESOURCE_KEYS[name] : undefined;
let resource: Section | undefined;
if (resourceKeys) {
const binding = lookup(s, 'binding');
if (binding) {
resource = sourceResources.get(`${name}:${valueOf(binding)}`);
if (!resource) {
console.error(
`[merge-wrangler] ${name} binding ${valueOf(binding)} is new upstream — this deployment has no id for it`
);
}
}
}

for (const line of s.lines) {
const key = keyOf(line);
if (!key) {
out.push(line);
continue;
}
if (!s.header && PRESERVED_TOP_KEYS.includes(key)) {
const own = lookup(sourceTop, key);
usedTopKeys.add(key);
out.push(own ?? line);
continue;
}
if (resource && resourceKeys!.includes(key)) {
out.push(lookup(resource, key) ?? line);
continue;
}
if (s.header === '[vars]' && sourceVars) {
out.push(lookup(sourceVars, key) ?? line);
continue;
}
out.push(line);
}

// Custom domains and account ids the template never declares.
if (!s.header) {
const extra = PRESERVED_TOP_KEYS.filter((k) => !usedTopKeys.has(k) && lookup(sourceTop, k));
if (extra.length) {
let at = out.length;
while (at > 0 && !keyOf(out[at - 1]!)) at--;
out.splice(at, 0, ...extra.map((k) => lookup(sourceTop, k)!));
}
}
if (s.header === '[vars]' && sourceVars) {
const declared = new Set(s.lines.map(keyOf).filter(Boolean));
for (const line of sourceVars.lines) {
const k = keyOf(line);
if (k && !declared.has(k)) out.push(line);
}
}
}

if (sourceVars && !template.some((s) => s.header === '[vars]')) {
out.push('', '[vars]', ...sourceVars.lines.filter((l) => keyOf(l)));
}

return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n');
}

if (import.meta.main) {
const [, , sourcePath, templatePath] = process.argv;
if (!sourcePath || !templatePath) {
console.error('usage: merge-wrangler.ts <deployment.toml> <upstream-template.toml>');
process.exit(1);
}
process.stdout.write(
mergeWrangler(readFileSync(sourcePath, 'utf8'), readFileSync(templatePath, 'utf8'))
);
}
1 change: 1 addition & 0 deletions shared/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type BlockFieldType =
| 'number'
| 'boolean'
| 'variable'
| 'device'
| 'integration'
| 'time'
| 'weekdays';
Expand Down
Loading