diff --git a/README.md b/README.md index acf6f37..dc1c346 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `.ino.bin` from `build//`. `arduino-cli compile --output-dir build` and PlatformIO's `.pio/build//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. diff --git a/deploy/wrangler.toml b/deploy/wrangler.toml index 474906d..37c3864 100644 --- a/deploy/wrangler.toml +++ b/deploy/wrangler.toml @@ -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" diff --git a/package.json b/package.json index 2f6dc64..a754523 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nodrix", "private": true, - "version": "1.0.1", + "version": "1.1.0", "type": "module", "workspaces": [ "worker", diff --git a/scripts/build-from-upstream.sh b/scripts/build-from-upstream.sh index 038a57a..f189294 100755 --- a/scripts/build-from-upstream.sh +++ b/scripts/build-from-upstream.sh @@ -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. @@ -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" @@ -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 @@ -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 diff --git a/scripts/merge-wrangler.test.ts b/scripts/merge-wrangler.test.ts new file mode 100644 index 0000000..d3eb176 --- /dev/null +++ b/scripts/merge-wrangler.test.ts @@ -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); +}); diff --git a/scripts/merge-wrangler.ts b/scripts/merge-wrangler.ts new file mode 100644 index 0000000..88ac0ef --- /dev/null +++ b/scripts/merge-wrangler.ts @@ -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 = { + 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(); + 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(); + + 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 '); + process.exit(1); + } + process.stdout.write( + mergeWrangler(readFileSync(sourcePath, 'utf8'), readFileSync(templatePath, 'utf8')) + ); +} diff --git a/shared/blocks/index.ts b/shared/blocks/index.ts index 981b588..54f7924 100644 --- a/shared/blocks/index.ts +++ b/shared/blocks/index.ts @@ -26,6 +26,7 @@ export type BlockFieldType = | 'number' | 'boolean' | 'variable' + | 'device' | 'integration' | 'time' | 'weekdays'; diff --git a/shared/blocks/triggers.ts b/shared/blocks/triggers.ts index 788915c..4416573 100644 --- a/shared/blocks/triggers.ts +++ b/shared/blocks/triggers.ts @@ -13,6 +13,12 @@ export const TRIGGER_CATALOG = [ ports: { out: ['out'] }, fields: [ { key: 'variable', label: 'Variable', type: 'variable', required: true }, + { + key: 'device', + label: 'Device', + type: 'device', + hint: 'Leave as any device to fire whichever board reports it.', + }, { key: 'operator', label: 'Condition', diff --git a/web/src/api.ts b/web/src/api.ts index 5e9e189..2bae14e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -47,6 +47,36 @@ async function request(method: string, path: string, body?: unknown): Promise } } +async function requestBytes(path: string): Promise { + progress.start(); + try { + const res = await fetch(path, { credentials: 'include' }); + if (res.status === 401) unauthorizedHandler?.(); + if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => null)); + return await res.arrayBuffer(); + } finally { + progress.done(); + } +} + +// The browser sets content-type (with the boundary) itself, so this can't go +// through request(), which always sends JSON. +async function upload(path: string, form: FormData): Promise { + progress.start(); + try { + const res = await fetch(path, { method: 'POST', body: form, credentials: 'include' }); + + if (res.status === 401) unauthorizedHandler?.(); + if (res.status === 204) return undefined as T; + + const payload = await res.json().catch(() => null); + if (!res.ok) throw new ApiError(res.status, payload); + return payload as T; + } finally { + progress.done(); + } +} + // Collapse concurrent identical GETs into one network request β€” e.g. two // components mounting at once both calling the same loader. The promise is shared // while in flight and dropped as soon as it settles, so this is a dedup of @@ -63,7 +93,9 @@ function getDeduped(path: string): Promise { export const api = { get: (path: string) => getDeduped(path), + bytes: (path: string) => requestBytes(path), post: (path: string, body?: unknown) => request('POST', path, body), + upload: (path: string, form: FormData) => upload(path, form), put: (path: string, body?: unknown) => request('PUT', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), del: (path: string) => request('DELETE', path), diff --git a/web/src/builder/grid.ts b/web/src/builder/grid.ts index 1f6689e..efcfd7d 100644 --- a/web/src/builder/grid.ts +++ b/web/src/builder/grid.ts @@ -20,5 +20,6 @@ export function normalizeLayout(layout: Layout): Layout { }), ...(layout.mobile !== undefined ? { mobile: layout.mobile } : {}), ...(layout.refresh !== undefined ? { refresh: layout.refresh } : {}), + ...(layout.device !== undefined ? { device: layout.device } : {}), }; } diff --git a/web/src/components/FirmwareUploadDialog.vue b/web/src/components/FirmwareUploadDialog.vue new file mode 100644 index 0000000..5edfe3b --- /dev/null +++ b/web/src/components/FirmwareUploadDialog.vue @@ -0,0 +1,134 @@ + + + diff --git a/web/src/layouts/Sidebar.vue b/web/src/layouts/Sidebar.vue index 4ae667b..c8c2ba2 100644 --- a/web/src/layouts/Sidebar.vue +++ b/web/src/layouts/Sidebar.vue @@ -22,7 +22,7 @@ const hasProject = computed(() => projId.value !== ''); type IconName = | 'home' | 'folder' | 'dashboards' | 'variable' | 'bolt' - | 'integrations' | 'users' | 'key' | 'settings' | 'audit'; + | 'integrations' | 'users' | 'key' | 'settings' | 'audit' | 'device'; type NavItem = { label: string; @@ -58,6 +58,13 @@ const projectScoped = computed(() => { matchPath: (path) => path === `/p/${id}/dashboards` || path.startsWith(`/p/${id}/d/`), }, + { + label: 'Devices', + to: `/p/${id}/device`, + icon: 'device', + disabled: !hasProject.value, + matchPath: (path) => path.startsWith(`/p/${id}/device`), + }, { label: 'Automations', to: `/p/${id}/automations`, @@ -105,6 +112,7 @@ const ICON_PATHS: Record = { key: 'M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z', settings: 'M4.5 12a7.5 7.5 0 0 0 .104 1.243l-1.32 1.02a.75.75 0 0 0-.176.957l1.5 2.598a.75.75 0 0 0 .912.328l1.561-.624a7.45 7.45 0 0 0 2.155 1.244l.236 1.66a.75.75 0 0 0 .742.643h3a.75.75 0 0 0 .742-.643l.237-1.66a7.45 7.45 0 0 0 2.154-1.244l1.561.624a.75.75 0 0 0 .912-.328l1.5-2.598a.75.75 0 0 0-.176-.957l-1.32-1.02A7.51 7.51 0 0 0 19.5 12c0-.42-.035-.832-.103-1.232l1.319-1.02a.75.75 0 0 0 .176-.958l-1.5-2.598a.75.75 0 0 0-.912-.327l-1.561.624A7.46 7.46 0 0 0 14.764 5.245l-.236-1.66A.75.75 0 0 0 13.786 3h-3a.75.75 0 0 0-.742.643l-.237 1.66a7.45 7.45 0 0 0-2.154 1.244l-1.561-.624a.75.75 0 0 0-.912.327l-1.5 2.598a.75.75 0 0 0 .176.958l1.32 1.02C4.535 11.168 4.5 11.58 4.5 12Zm10.5 0a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z', audit: 'M9 5h6m-6 0H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 1 1 6 0M9 12h6m-6 4h4', + device: 'M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z', }; function iconFor(name: IconName): FunctionalComponent { diff --git a/web/src/pages/Projects.vue b/web/src/pages/Projects.vue index 33daeda..9ed813a 100644 --- a/web/src/pages/Projects.vue +++ b/web/src/pages/Projects.vue @@ -105,6 +105,13 @@ async function removeProject(p: Project) { if (editing.value?.id === p.id) editing.value = null; } +// Navigation, not fetch β€” a Blob would defeat the streaming. +function exportProject(p: Project, event: Event) { + event.stopPropagation(); + openMenuFor.value = null; + window.location.href = `/v1/admin/projects/${p.id}/export`; +} + function deleteFromMenu(p: Project, event: Event) { event.stopPropagation(); openMenuFor.value = null; @@ -228,6 +235,11 @@ watch( class="block w-full px-3 py-1.5 text-left text-xs hover:bg-neutral-100 dark:hover:bg-neutral-800" @click="startEdit(p, $event)" >Edit project + +