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/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/package.json b/web/package.json index 88243f0..efe66d4 100644 --- a/web/package.json +++ b/web/package.json @@ -10,11 +10,17 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/state": "^6.7.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.9", "@nodrix/blocks-shared": "*", "@nodrix/integrations-shared": "*", "@nodrix/widgets-shared": "*", "@vue-flow/core": "^1.48.2", "better-auth": "^1.6.11", + "codemirror": "^6.0.2", + "esptool-js": "^0.6.1", "grid-layout-plus": "^1.1.1", "pinia": "^2.3.0", "reka-ui": "^2.0.2", @@ -23,6 +29,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/w3c-web-serial": "^1.0.8", "@vitejs/plugin-vue": "^5.2.1", "tailwindcss": "^4.0.0", "typescript": "^5.6.3", diff --git a/web/src/api.ts b/web/src/api.ts index 5e9e189..5a9028f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -47,6 +47,18 @@ 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(); + } +} + // 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,6 +75,7 @@ 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), put: (path: string, body?: unknown) => request('PUT', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), 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/CodeEditor.vue b/web/src/components/CodeEditor.vue new file mode 100644 index 0000000..c840916 --- /dev/null +++ b/web/src/components/CodeEditor.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/src/composables/useEspFlasher.ts b/web/src/composables/useEspFlasher.ts new file mode 100644 index 0000000..27c1eda --- /dev/null +++ b/web/src/composables/useEspFlasher.ts @@ -0,0 +1,79 @@ +import { ref } from 'vue'; +import { ESPLoader, Transport } from 'esptool-js'; +import { useSerialPort, emit } from './useSerialPort'; + +export type FlashPart = { data: Uint8Array; address: number }; + +export type FlashPhase = 'idle' | 'connecting' | 'writing' | 'done' | 'failed'; + +// main() handshakes with the ROM loader at 115200, then negotiates up to this. +const WRITE_BAUD = 460800; + +const phase = ref('idle'); +const progress = ref(0); +const chip = ref(null); +const error = ref(null); + +// esptool-js reports out of band — none of this arrives over the port, which is +// speaking binary to the ROM loader at the time. +const terminal = { + clean: () => {}, + write: (data: string) => { if (data.trim()) emit('flash', data.trim()); }, + writeLine: (data: string) => { if (data.trim()) emit('flash', data.trim()); }, +}; + +export function useEspFlasher() { + const { claim } = useSerialPort(); + + async function flash(parts: FlashPart[]): Promise { + if (!parts.length) throw new Error('Nothing to flash'); + phase.value = 'connecting'; + progress.value = 0; + error.value = null; + + const total = parts.reduce((n, p) => n + p.data.length, 0); + const written = new Map(); + + try { + await claim('flash', async (port) => { + const transport = new Transport(port, false); + const loader = new ESPLoader({ + transport, + baudrate: WRITE_BAUD, + terminal, + }); + try { + chip.value = await loader.main(); + phase.value = 'writing'; + await loader.writeFlash({ + fileArray: parts, + flashMode: 'keep', + flashFreq: 'keep', + flashSize: 'keep', + eraseAll: false, + compress: true, + reportProgress: (fileIndex, bytes) => { + written.set(fileIndex, bytes); + const done = [...written.values()].reduce((n, v) => n + v, 0); + progress.value = total ? Math.min(1, done / total) : 0; + }, + }); + // Without this the board sits in the ROM loader until it's unplugged. + await loader.after(); + } finally { + await transport.disconnect(); + } + }); + phase.value = 'done'; + progress.value = 1; + return true; + } catch (e) { + error.value = (e as Error).message; + emit('flash', `Failed: ${error.value}`); + phase.value = 'failed'; + return false; + } + } + + return { flash, phase, progress, chip, error }; +} diff --git a/web/src/composables/useSerialDiagnosis.ts b/web/src/composables/useSerialDiagnosis.ts new file mode 100644 index 0000000..3d40137 --- /dev/null +++ b/web/src/composables/useSerialDiagnosis.ts @@ -0,0 +1,105 @@ +import { computed, type ComputedRef } from 'vue'; +import { useSerialLog, type LogEntry } from './useSerialLog'; + +export type Diagnosis = { + tone: 'ok' | 'warn' | 'error'; + headline: string; + detail?: string; +}; + +type Rule = { re: RegExp; build: (m: RegExpMatchArray) => Diagnosis }; + +const RULES: Rule[] = [ + { + re: /^no wifi network set/, + build: () => ({ + tone: 'error', + headline: 'No Wi-Fi network configured', + detail: 'The sketch never called addAP(), so it has nothing to join.', + }), + }, + { + re: /^connect refused/, + build: () => ({ + tone: 'error', + headline: 'The server refused the connection', + detail: 'The socket never opened once. The token is wrong, or the host is.', + }), + }, + { + re: /-> 401/, + build: () => ({ tone: 'error', headline: 'Token rejected', detail: 'This token is not valid for this instance.' }), + }, + { + re: /-> 403/, + build: () => ({ tone: 'error', headline: 'Token has no access to this project' }), + }, + { + re: /-> 404/, + build: () => ({ tone: 'error', headline: 'No such endpoint', detail: 'The host is probably wrong.' }), + }, + { + re: /-> 429/, + build: () => ({ tone: 'warn', headline: 'Rate limited', detail: 'The board is sending faster than the instance accepts.' }), + }, + { + re: /-> -\d+ \(no connection/, + build: () => ({ + tone: 'error', + headline: "Can't reach the server", + detail: 'DNS, TLS or the network dropped it before any reply came back.', + }), + }, + { + re: /^socket error/, + build: () => ({ tone: 'error', headline: 'Socket error' }), + }, + { + re: /^server error:\s*(\S+)/, + build: (m) => ({ tone: 'error', headline: `Server rejected the message`, detail: `It replied with ${m[1]}.` }), + }, + { + re: /^connected$/, + build: () => ({ tone: 'ok', headline: 'Connected' }), + }, + { + re: /^disconnected$/, + build: () => ({ tone: 'warn', headline: 'Disconnected', detail: 'The link was up and dropped. It will retry.' }), + }, +]; + +function match(entry: LogEntry): Diagnosis | null { + if (entry.tag !== 'nodrix') return null; + for (const rule of RULES) { + const m = entry.text.match(rule.re); + if (m) return rule.build(m); + } + return null; +} + +// Wi-Fi state is separate from cloud state: knowing the network is up is what +// turns "disconnected" into a statement about the server rather than the radio. +function wifiUp(entries: LogEntry[]): boolean { + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]!; + if (e.tag === 'nodrix' && /^wifi connected/.test(e.text)) return true; + } + return false; +} + +export function diagnose(list: LogEntry[]): Diagnosis | null { + for (let i = list.length - 1; i >= 0; i--) { + const found = match(list[i]!); + if (!found) continue; + if (found.tone !== 'ok' && wifiUp(list)) { + return { ...found, detail: `Wi-Fi is up. ${found.detail ?? ''}`.trim() }; + } + return found; + } + return null; +} + +export function useSerialDiagnosis(): ComputedRef { + const { entries } = useSerialLog(); + return computed(() => diagnose(entries.value)); +} diff --git a/web/src/composables/useSerialLog.ts b/web/src/composables/useSerialLog.ts new file mode 100644 index 0000000..bab7072 --- /dev/null +++ b/web/src/composables/useSerialLog.ts @@ -0,0 +1,117 @@ +import { computed, ref, shallowRef } from 'vue'; +import { useSerialPort, type LogLine, type LogSource } from './useSerialPort'; + +export type LogLevel = 'info' | 'ok' | 'warn' | 'error' | 'system'; +export type LogEntry = { + id: number; + at: number; + source: LogSource; + text: string; + level: LogLevel; + tag?: string; +}; + +const MAX_ENTRIES = 2000; +// A wrong baud rate reads as replacement and control characters, not silence. +const GARBLED_RATIO = 0.2; +const GARBLED_CHARS = /[\uFFFD\u0000-\u0008\u000B\u000C\u000E-\u001F]/g; + +const BOOT_PREFIXES = ['rst:0x', 'ets ', 'load 0x', 'configsip:', 'clk_drv:', 'mode:DIO', 'entry 0x']; + +const NODRIX_LEVELS: [RegExp, LogLevel][] = [ + [/^no wifi network set/, 'error'], + [/^wifi connected/, 'ok'], + [/^connected$/, 'ok'], + [/^disconnected$/, 'warn'], + [/^server error/, 'error'], + [/^unhandled control/, 'warn'], + [/^key dropped/, 'warn'], + [/^telemetry buffer full/, 'warn'], +]; + +function classify(line: LogLine, id: number): LogEntry { + const base = { id, at: line.at, source: line.source, text: line.text }; + if (line.source === 'flash') return { ...base, level: 'info', tag: 'flash' }; + if (line.source === 'system') return { ...base, level: 'system' }; + + const nodrix = /^\[nodrix\]\s*(.*)$/.exec(line.text); + if (nodrix) { + const body = nodrix[1] ?? ''; + const match = NODRIX_LEVELS.find(([re]) => re.test(body)); + return { ...base, text: body, level: match?.[1] ?? 'info', tag: 'nodrix' }; + } + + const idf = /^([EWIDV])\s\(\d+\)\s/.exec(line.text); + if (idf) { + const level: LogLevel = idf[1] === 'E' ? 'error' : idf[1] === 'W' ? 'warn' : 'info'; + return { ...base, level, tag: 'esp' }; + } + + if (BOOT_PREFIXES.some((p) => line.text.startsWith(p))) return { ...base, level: 'system', tag: 'boot' }; + + return { ...base, level: 'info' }; +} + +const entries = shallowRef([]); +const paused = ref(false); +let held: LogEntry[] = []; +let queue: LogEntry[] = []; +let frame: number | null = null; +let nextId = 1; + +function cap(list: LogEntry[]): LogEntry[] { + return list.length > MAX_ENTRIES ? list.slice(list.length - MAX_ENTRIES) : list; +} + +// A board at 115200 can outrun per-line reactivity, so commit once a frame. +function schedule() { + if (frame !== null) return; + frame = requestAnimationFrame(() => { + frame = null; + if (!queue.length) return; + entries.value = cap(entries.value.concat(queue)); + queue = []; + }); +} + +useSerialPort().onLine((line) => { + const entry = classify(line, nextId++); + if (paused.value) { + held = cap(held.concat(entry)); + return; + } + queue.push(entry); + schedule(); +}); + +const garbled = computed(() => { + const recent = entries.value.filter((e) => e.source === 'device').slice(-20); + if (recent.length < 5) return false; + const text = recent.map((e) => e.text).join(''); + if (!text.length) return false; + return (text.match(GARBLED_CHARS) ?? []).length / text.length > GARBLED_RATIO; +}); + +function setPaused(on: boolean) { + paused.value = on; + if (!on && held.length) { + entries.value = cap(entries.value.concat(held)); + held = []; + } +} + +function clear() { + entries.value = []; + queue = []; + held = []; +} + +function toText(): string { + return entries.value + .map((e) => `${new Date(e.at).toISOString().slice(11, 23)} ${e.tag ? `[${e.tag}] ` : ''}${e.text}`) + .join('\n'); +} + +export function useSerialLog() { + return { entries, paused, garbled, setPaused, clear, toText }; +} diff --git a/web/src/composables/useSerialPort.ts b/web/src/composables/useSerialPort.ts new file mode 100644 index 0000000..54fea15 --- /dev/null +++ b/web/src/composables/useSerialPort.ts @@ -0,0 +1,216 @@ +// Single owner of the page's one SerialPort. The monitor holds an exclusive +// reader and the flasher needs it closed, so both go through claim(). + +import { ref, shallowRef } from 'vue'; + +export type PortMode = 'monitor' | 'flash' | 'provision'; +export type PortState = 'unsupported' | 'closed' | 'opening' | 'open' | 'busy'; + +// Flash progress comes from esptool-js, not over the port. +export type LogSource = 'device' | 'flash' | 'system'; +export type LogLine = { source: LogSource; text: string; at: number }; + +export const serialSupported = + typeof navigator !== 'undefined' && 'serial' in navigator && window.isSecureContext; + +// 74880 is the ESP8266 boot ROM's rate; its reset banner is mojibake elsewhere. +export const BAUD_RATES = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]; + +// The SDK dots through a Wi-Fi connect without newlines. +const PARTIAL_LINE_FLUSH_MS = 250; + +const port = shallowRef(null); +const state = ref(serialSupported ? 'closed' : 'unsupported'); +const mode = ref(null); +const baudRate = ref(115200); +const lastError = ref(null); + +const listeners = new Set<(line: LogLine) => void>(); +let reader: ReadableStreamDefaultReader | null = null; +let readLoop: Promise | null = null; +let carry = ''; +let flushTimer: ReturnType | null = null; + +let chain: Promise = Promise.resolve(); +function enqueue(fn: () => Promise): Promise { + const run = chain.then(fn, fn); + chain = run.catch(() => {}); + return run; +} + +function deliver(source: LogSource, text: string) { + const line = { source, text, at: Date.now() }; + for (const fn of listeners) fn(line); +} + +export function emit(source: LogSource, text: string) { + deliver(source, text); +} + +function flushCarry() { + if (!carry) return; + deliver('device', carry); + carry = ''; +} + +function absorb(chunk: string) { + carry += chunk.replace(/\r/g, ''); + const parts = carry.split('\n'); + carry = parts.pop() ?? ''; + for (const line of parts) deliver('device', line); + if (flushTimer) clearTimeout(flushTimer); + if (carry) flushTimer = setTimeout(flushCarry, PARTIAL_LINE_FLUSH_MS); +} + +async function pump(p: SerialPort) { + const decoder = new TextDecoder(); + while (p.readable && mode.value === 'monitor') { + reader = p.readable.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) return; + if (value) absorb(decoder.decode(value, { stream: true })); + } + } catch (e) { + lastError.value = (e as Error).message; + return; + } finally { + try { reader.releaseLock(); } catch { /* already released */ } + reader = null; + } + } +} + +async function teardown() { + mode.value = null; + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + flushCarry(); + if (reader) { try { await reader.cancel(); } catch { /* stream already dead */ } } + if (readLoop) { try { await readLoop; } catch { /* surfaced via lastError */ } readLoop = null; } + const p = port.value; + if (p) { try { await p.close(); } catch { /* already closed */ } } + state.value = 'closed'; +} + +async function request(): Promise { + if (!serialSupported) throw new Error('Web Serial is unavailable in this browser'); + try { + port.value = await navigator.serial.requestPort(); + lastError.value = null; + return true; + } catch { + return false; + } +} + +async function beginMonitor(p: SerialPort, baud: number, note: string): Promise { + state.value = 'opening'; + try { + await p.open({ baudRate: baud }); + } catch (e) { + state.value = 'closed'; + lastError.value = (e as Error).message; + throw e; + } + baudRate.value = baud; + mode.value = 'monitor'; + state.value = 'open'; + deliver('system', `${note} at ${baud} baud`); + readLoop = pump(p).finally(() => { + if (mode.value === 'monitor') { mode.value = null; state.value = 'closed'; } + }); +} + +function startMonitor(baud: number): Promise { + return enqueue(async () => { + const p = port.value; + if (!p) throw new Error('No port selected'); + if (mode.value === 'monitor') return; + await beginMonitor(p, baud, 'Connected'); + }); +} + +function stopMonitor(): Promise { + return enqueue(teardown); +} + +// Web Serial can't reconfigure a live port; buffered output is lost. +function setBaud(baud: number): Promise { + return enqueue(async () => { + if (baud === baudRate.value && mode.value === 'monitor') return; + const p = port.value; + const wasMonitoring = mode.value === 'monitor'; + if (wasMonitoring) await teardown(); + baudRate.value = baud; + if (wasMonitoring && p) await beginMonitor(p, baud, 'Reconnected'); + }); +} + +// esptool-js opens and closes the port itself, so hand it over closed. +function claim(next: PortMode, fn: (raw: SerialPort) => Promise): Promise { + return enqueue(async () => { + const p = port.value; + if (!p) throw new Error('No port selected'); + const wasMonitoring = mode.value === 'monitor'; + const baud = baudRate.value; + await teardown(); + mode.value = next; + state.value = 'busy'; + deliver('system', `Monitor released — port handed to ${next}`); + try { + return await fn(p); + } finally { + mode.value = null; + state.value = 'closed'; + if (wasMonitoring) { + try { await beginMonitor(p, baud, 'Monitor resumed'); } catch { /* surfaced as lastError */ } + } + } + }); +} + +async function write(data: string | Uint8Array): Promise { + const p = port.value; + if (!p?.writable) throw new Error('Port is not open for writing'); + const writer = p.writable.getWriter(); + try { + await writer.write(typeof data === 'string' ? new TextEncoder().encode(data) : data); + } finally { + writer.releaseLock(); + } +} + +function onLine(fn: (line: LogLine) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +if (serialSupported) { + navigator.serial.addEventListener('disconnect', (e) => { + if (e.target !== port.value) return; + port.value = null; + mode.value = null; + state.value = 'closed'; + lastError.value = 'Device disconnected'; + deliver('system', 'Device disconnected'); + }); +} + +export function useSerialPort() { + return { + supported: serialSupported, + port, + state, + mode, + baudRate, + lastError, + request, + startMonitor, + stopMonitor, + setBaud, + claim, + write, + onLine, + }; +} diff --git a/web/src/layouts/Sidebar.vue b/web/src/layouts/Sidebar.vue index 4ae667b..f6070d2 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: 'Device', + 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 + +