From b451aaf29acc79edb575ebe42cb5abd41f83a13a Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 19 Apr 2026 11:35:00 -0500 Subject: [PATCH] feat(rnode): add advanced tools, diagnostics, and device management UI components for RNode firmware flashing and configuration --- .../src/frontend/components/call/CallPage.vue | 26 +- .../interfaces/AddInterfacePage.vue | 740 ++++++++- .../messages/ConversationViewer.vue | 68 +- .../components/rnode/RNodeAdvancedTools.vue | 66 + .../components/rnode/RNodeBluetoothPanel.vue | 52 + .../rnode/RNodeCapabilitiesBanner.vue | 115 ++ .../components/rnode/RNodeDeviceDisplay.vue | 30 + .../components/rnode/RNodeDeviceSelector.vue | 181 +++ .../rnode/RNodeDiagnosticsPanel.vue | 112 ++ .../rnode/RNodeFirmwareSelector.vue | 127 ++ .../components/rnode/RNodeFlashAction.vue | 59 + .../components/rnode/RNodeProvisionPanel.vue | 68 + .../components/rnode/RNodeTncPanel.vue | 106 ++ .../components/stickers/StickerEditor.vue | 625 -------- .../stickers/StickerPacksManager.vue | 40 +- .../components/stickers/StickerView.vue | 90 +- .../components/tools/RNodeFlasherPage.vue | 1401 +++++++---------- .../src/frontend/js/frontendModuleMap.js | 27 - .../src/frontend/js/rnode/AndroidBridge.js | 117 ++ .../src/frontend/js/rnode/Capabilities.js | 192 +++ .../src/frontend/js/rnode/Diagnostics.js | 255 +++ .../js/rnode/transports/BluetoothTransport.js | 218 +++ .../js/rnode/transports/SerialTransport.js | 104 ++ .../frontend/js/rnode/transports/Transport.js | 56 + .../js/rnode/transports/WifiTransport.js | 123 ++ 25 files changed, 3262 insertions(+), 1736 deletions(-) create mode 100644 meshchatx/src/frontend/components/rnode/RNodeAdvancedTools.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeBluetoothPanel.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeDeviceDisplay.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeDeviceSelector.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeDiagnosticsPanel.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeFirmwareSelector.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeFlashAction.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeProvisionPanel.vue create mode 100644 meshchatx/src/frontend/components/rnode/RNodeTncPanel.vue delete mode 100644 meshchatx/src/frontend/components/stickers/StickerEditor.vue delete mode 100644 meshchatx/src/frontend/js/frontendModuleMap.js create mode 100644 meshchatx/src/frontend/js/rnode/AndroidBridge.js create mode 100644 meshchatx/src/frontend/js/rnode/Capabilities.js create mode 100644 meshchatx/src/frontend/js/rnode/Diagnostics.js create mode 100644 meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js create mode 100644 meshchatx/src/frontend/js/rnode/transports/SerialTransport.js create mode 100644 meshchatx/src/frontend/js/rnode/transports/Transport.js create mode 100644 meshchatx/src/frontend/js/rnode/transports/WifiTransport.js diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 1caa65a..395172a 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -984,7 +984,7 @@

Dependencies Missing

-

+

Voicemail requires `espeak-ng` to generate greetings. Please install it on your system.

-

- Voicemail requires `ffmpeg` to process audio files. Please install it on your - system. -

@@ -1018,7 +1011,7 @@
- - +
- - + + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeBluetoothPanel.vue b/meshchatx/src/frontend/components/rnode/RNodeBluetoothPanel.vue new file mode 100644 index 0000000..fdf6ca1 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeBluetoothPanel.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue new file mode 100644 index 0000000..f612fab --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeDeviceDisplay.vue b/meshchatx/src/frontend/components/rnode/RNodeDeviceDisplay.vue new file mode 100644 index 0000000..d94f254 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeDeviceDisplay.vue @@ -0,0 +1,30 @@ + + + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeDeviceSelector.vue b/meshchatx/src/frontend/components/rnode/RNodeDeviceSelector.vue new file mode 100644 index 0000000..380ed73 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeDeviceSelector.vue @@ -0,0 +1,181 @@ + + + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeDiagnosticsPanel.vue b/meshchatx/src/frontend/components/rnode/RNodeDiagnosticsPanel.vue new file mode 100644 index 0000000..adef15b --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeDiagnosticsPanel.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeFirmwareSelector.vue b/meshchatx/src/frontend/components/rnode/RNodeFirmwareSelector.vue new file mode 100644 index 0000000..813926f --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeFirmwareSelector.vue @@ -0,0 +1,127 @@ + + + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeFlashAction.vue b/meshchatx/src/frontend/components/rnode/RNodeFlashAction.vue new file mode 100644 index 0000000..7e52524 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeFlashAction.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeProvisionPanel.vue b/meshchatx/src/frontend/components/rnode/RNodeProvisionPanel.vue new file mode 100644 index 0000000..e9ce449 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeProvisionPanel.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/meshchatx/src/frontend/components/rnode/RNodeTncPanel.vue b/meshchatx/src/frontend/components/rnode/RNodeTncPanel.vue new file mode 100644 index 0000000..3fddcd7 --- /dev/null +++ b/meshchatx/src/frontend/components/rnode/RNodeTncPanel.vue @@ -0,0 +1,106 @@ + + + + + + + diff --git a/meshchatx/src/frontend/components/stickers/StickerEditor.vue b/meshchatx/src/frontend/components/stickers/StickerEditor.vue deleted file mode 100644 index b53369a..0000000 --- a/meshchatx/src/frontend/components/stickers/StickerEditor.vue +++ /dev/null @@ -1,625 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/stickers/StickerPacksManager.vue b/meshchatx/src/frontend/components/stickers/StickerPacksManager.vue index 3cfa54d..9cf17ca 100644 --- a/meshchatx/src/frontend/components/stickers/StickerPacksManager.vue +++ b/meshchatx/src/frontend/components/stickers/StickerPacksManager.vue @@ -9,14 +9,6 @@ {{ $t("sticker_packs.create") }} -
-
- - - diff --git a/meshchatx/src/frontend/js/frontendModuleMap.js b/meshchatx/src/frontend/js/frontendModuleMap.js deleted file mode 100644 index 9b13323..0000000 --- a/meshchatx/src/frontend/js/frontendModuleMap.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Frontend module map: primary entry points, large feature surfaces, and shared layers. - * - * Entry: meshchatx/src/frontend/main.js - * - * Application shell: components/App.vue (routing, WebSocket shell, sidebar, global modals) - * - * Feature surfaces (orchestration-heavy .vue files): - * - components/messages/ConversationViewer.vue - * - components/settings/SettingsPage.vue - * - components/call/CallPage.vue - * - components/map/MapPage.vue - * - * Shared state and events: - * - js/GlobalState.js, js/GlobalEmitter.js - * - js/KeyboardShortcuts.js, js/WebSocketConnection.js - * - * Extracted domain helpers (non-UI): - * - js/settings/settingsConfigService.js - * - js/settings/settingsTransportService.js - * - js/settings/settingsMaintenanceClient.js - * - js/settings/settingsVisualiserPrefs.js - * - components/messages/conversationMessageHelpers.js - * - components/messages/conversationScroll.js - */ - -export const FRONTEND_MODULE_MAP_VERSION = 1; diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.js new file mode 100644 index 0000000..e0afbae --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.js @@ -0,0 +1,117 @@ +/** + * Thin JS wrapper around the native MeshChatXAndroid bridge. + * + * The bridge methods are added by the Android WebView side + * (see android/app/src/main/java/com/meshchatx/MainActivity.java). + * Each method may be missing on older builds, so all helpers degrade + * gracefully and return safe defaults. + * + * The wrapper is also fully testable: a custom bridge object can be + * injected via the constructor. + */ + +const PERM_BLUETOOTH = "bluetooth"; +const PERM_USB = "usb"; + +function pickEnv() { + if (typeof window !== "undefined") { + return window; + } + if (typeof globalThis !== "undefined") { + return globalThis; + } + return {}; +} + +function safeCall(fn, fallback) { + try { + const result = fn(); + return result === undefined ? fallback : result; + } catch { + return fallback; + } +} + +export default class AndroidBridge { + constructor(bridge = null, env = null) { + this.env = env || pickEnv(); + this.bridge = bridge || this.env.MeshChatXAndroid || null; + } + + isAvailable() { + return Boolean(this.bridge); + } + + /** + * Check whether a runtime permission group is currently granted on the + * Android host. Returns true on non-android (no-op) so calling code + * can chain checks without branching. + */ + hasPermission(permissionGroup) { + if (!this.bridge) { + return true; + } + if (permissionGroup === PERM_BLUETOOTH && typeof this.bridge.hasBluetoothPermissions === "function") { + return safeCall(() => Boolean(this.bridge.hasBluetoothPermissions()), false); + } + if (permissionGroup === PERM_USB && typeof this.bridge.hasUsbPermissions === "function") { + return safeCall(() => Boolean(this.bridge.hasUsbPermissions()), false); + } + return false; + } + + /** + * Request a runtime permission group from Android. Resolves to true if + * the permission was already granted (or the call was made on a non- + * android build). The actual grant result is delivered asynchronously + * by the OS, so callers should re-check via hasPermission afterwards. + */ + async requestPermission(permissionGroup) { + if (!this.bridge) { + return true; + } + if (permissionGroup === PERM_BLUETOOTH && typeof this.bridge.requestBluetoothPermissions === "function") { + return safeCall(() => { + this.bridge.requestBluetoothPermissions(); + return true; + }, false); + } + if (permissionGroup === PERM_USB && typeof this.bridge.requestUsbPermissions === "function") { + return safeCall(() => { + this.bridge.requestUsbPermissions(); + return true; + }, false); + } + return false; + } + + openBluetoothSettings() { + if (!this.bridge || typeof this.bridge.openBluetoothSettings !== "function") { + return false; + } + return safeCall(() => { + this.bridge.openBluetoothSettings(); + return true; + }, false); + } + + openUsbSettings() { + if (!this.bridge || typeof this.bridge.openUsbSettings !== "function") { + return false; + } + return safeCall(() => { + this.bridge.openUsbSettings(); + return true; + }, false); + } + + getPlatform() { + if (!this.bridge || typeof this.bridge.getPlatform !== "function") { + return null; + } + return safeCall(() => this.bridge.getPlatform(), null); + } +} + +AndroidBridge.PERM_BLUETOOTH = PERM_BLUETOOTH; +AndroidBridge.PERM_USB = PERM_USB; diff --git a/meshchatx/src/frontend/js/rnode/Capabilities.js b/meshchatx/src/frontend/js/rnode/Capabilities.js new file mode 100644 index 0000000..154203c --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/Capabilities.js @@ -0,0 +1,192 @@ +/** + * Runtime capability detection for RNode flasher. + * + * Determines which connection transports are available in the current + * environment (Web Serial, Web Bluetooth, WebUSB polyfill, WiFi/HTTP) + * and exposes structured reasons when something is unsupported so the UI + * can render actionable guidance. + * + * Pure functions, no DOM mutation, safe to import in tests. + */ + +export const TRANSPORT_SERIAL = "serial"; +export const TRANSPORT_BLUETOOTH = "bluetooth"; +export const TRANSPORT_WIFI = "wifi"; + +const ANDROID_RE = /android/i; +const ELECTRON_RE = /electron/i; + +function pickGlobal(provided) { + if (provided) { + return provided; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof globalThis !== "undefined") { + return globalThis; + } + return {}; +} + +function detectPlatform(env) { + const ua = env.navigator?.userAgent ?? ""; + const isAndroid = ANDROID_RE.test(ua); + const isElectron = ELECTRON_RE.test(ua) || Boolean(env.electron); + const hasMeshChatXAndroid = Boolean(env.MeshChatXAndroid); + return { + isAndroid, + isElectron, + hasMeshChatXAndroid, + isSecureContext: Boolean(env.isSecureContext), + userAgent: ua, + }; +} + +function detectSerial(env, platform) { + const hasNative = Boolean(env.navigator?.serial); + const hasUsbPolyfillTarget = Boolean(env.navigator?.usb); + const hasPolyfillModule = Boolean(env.serial); + + if (hasNative) { + return { + available: true, + kind: "native", + polyfilled: false, + reason: null, + }; + } + if (hasUsbPolyfillTarget && hasPolyfillModule) { + return { + available: true, + kind: "polyfill", + polyfilled: true, + reason: null, + }; + } + if (hasUsbPolyfillTarget && !hasPolyfillModule) { + return { + available: false, + kind: "polyfill-pending", + polyfilled: false, + reason: "polyfill_not_loaded", + }; + } + if (platform.isAndroid) { + return { + available: false, + kind: "none", + polyfilled: false, + reason: "android_webview_no_serial", + }; + } + return { + available: false, + kind: "none", + polyfilled: false, + reason: "browser_unsupported", + }; +} + +function detectBluetooth(env, platform) { + const hasNative = Boolean(env.navigator?.bluetooth); + if (hasNative) { + return { + available: true, + kind: "web-bluetooth", + reason: null, + }; + } + if (platform.hasMeshChatXAndroid) { + return { + available: false, + kind: "android-bridge", + reason: "android_bridge_not_implemented", + }; + } + return { + available: false, + kind: "none", + reason: platform.isSecureContext ? "browser_unsupported" : "insecure_context", + }; +} + +function detectWifi() { + return { + available: true, + kind: "http", + reason: null, + }; +} + +/** + * Inspect the environment and return a capabilities snapshot. + * + * @param {object} [overrides] + * @param {object} [overrides.env] alternative global object (window-like) for tests + * @returns {{ + * platform: object, + * transports: { serial: object, bluetooth: object, wifi: object }, + * anyAvailable: boolean, + * }} + */ +export function detectCapabilities(overrides = {}) { + const env = pickGlobal(overrides.env); + const platform = detectPlatform(env); + const transports = { + [TRANSPORT_SERIAL]: detectSerial(env, platform), + [TRANSPORT_BLUETOOTH]: detectBluetooth(env, platform), + [TRANSPORT_WIFI]: detectWifi(), + }; + const anyAvailable = + transports[TRANSPORT_SERIAL].available || + transports[TRANSPORT_BLUETOOTH].available || + transports[TRANSPORT_WIFI].available; + return { platform, transports, anyAvailable }; +} + +/** + * Choose the most appropriate default transport given current capabilities. + * + * Order of preference: native serial, polyfill serial, web bluetooth, wifi. + */ +export function pickDefaultTransport(capabilities) { + const t = capabilities?.transports ?? {}; + if (t[TRANSPORT_SERIAL]?.available) { + return TRANSPORT_SERIAL; + } + if (t[TRANSPORT_BLUETOOTH]?.available) { + return TRANSPORT_BLUETOOTH; + } + return TRANSPORT_WIFI; +} + +/** + * Return a list of human-readable, translation-aware suggestions for a + * transport that is unavailable. The caller maps these keys to i18n strings. + */ +export function transportSuggestionKeys(capabilities, transportName) { + const transport = capabilities?.transports?.[transportName]; + if (!transport || transport.available) { + return []; + } + const platform = capabilities.platform ?? {}; + const reason = transport.reason ?? "unknown"; + const suggestions = [`tools.rnode_flasher.support.${transportName}.${reason}`]; + if (transportName === TRANSPORT_SERIAL && platform.isAndroid) { + suggestions.push("tools.rnode_flasher.support.serial.android_use_chrome"); + } + if (transportName === TRANSPORT_BLUETOOTH && !platform.isSecureContext) { + suggestions.push("tools.rnode_flasher.support.bluetooth.requires_https"); + } + return suggestions; +} + +export default { + detectCapabilities, + pickDefaultTransport, + transportSuggestionKeys, + TRANSPORT_SERIAL, + TRANSPORT_BLUETOOTH, + TRANSPORT_WIFI, +}; diff --git a/meshchatx/src/frontend/js/rnode/Diagnostics.js b/meshchatx/src/frontend/js/rnode/Diagnostics.js new file mode 100644 index 0000000..8455400 --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/Diagnostics.js @@ -0,0 +1,255 @@ +import RNodeUtils from "./RNodeUtils.js"; + +/** + * Smart diagnostics for connected RNode devices. + * + * Given an open RNode handle, inspect()/inspectQuiet() will try to read the + * essential identification & state from the device and return a structured + * report including i18n suggestion keys for any issues detected. + * + * The suggestion keys map directly to entries under + * tools.rnode_flasher.diagnostics.* in the locale files, so the UI does not + * need any logic to translate raw issue identifiers. + */ + +export const ISSUE_NOT_PROVISIONED = "not_provisioned"; +export const ISSUE_FIRMWARE_HASH_MISSING = "firmware_hash_missing"; +export const ISSUE_FIRMWARE_HASH_MISMATCH = "firmware_hash_mismatch"; +export const ISSUE_PRODUCT_MISMATCH = "product_mismatch"; +export const ISSUE_NO_FIRMWARE_VERSION = "no_firmware_version"; +export const ISSUE_DETECT_FAILED = "detect_failed"; +export const ISSUE_READ_TIMEOUT = "read_timeout"; + +const ALL_ISSUE_KEYS = new Set([ + ISSUE_NOT_PROVISIONED, + ISSUE_FIRMWARE_HASH_MISSING, + ISSUE_FIRMWARE_HASH_MISMATCH, + ISSUE_PRODUCT_MISMATCH, + ISSUE_NO_FIRMWARE_VERSION, + ISSUE_DETECT_FAILED, + ISSUE_READ_TIMEOUT, +]); + +function suggestionKeysFor(issue) { + return [`tools.rnode_flasher.diagnostics.suggestions.${issue}`]; +} + +function withTimeout(promise, ms, code) { + let timer = null; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + const err = new Error(code || "timeout"); + err.code = code || "TIMEOUT"; + reject(err); + }, ms); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} + +function safeArrayEqual(a, b) { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if ((a[i] & 0xff) !== (b[i] & 0xff)) { + return false; + } + } + return true; +} + +/** + * Run a diagnostics sweep against a connected RNode. + * + * @param {object} rnode RNode instance (already detected/open) + * @param {{ expectedProductId?: number, expectedModelId?: number, timeoutMs?: number }} [options] + * @returns {Promise<{ + * firmwareVersion: string|null, + * platform: number|null, + * board: number|null, + * mcu: number|null, + * eepromBytes: number[]|null, + * romDetails: object|null, + * firmwareHash: number[]|null, + * targetFirmwareHash: number[]|null, + * isProvisioned: boolean, + * issues: string[], + * suggestionKeys: string[], + * summary: object, + * }>} + */ +export async function diagnose(rnode, options = {}) { + const timeoutMs = options.timeoutMs || 4000; + const result = { + firmwareVersion: null, + platform: null, + board: null, + mcu: null, + eepromBytes: null, + romDetails: null, + firmwareHash: null, + targetFirmwareHash: null, + isProvisioned: false, + issues: [], + suggestionKeys: [], + summary: {}, + }; + + const addIssue = (issue) => { + if (!ALL_ISSUE_KEYS.has(issue)) { + return; + } + if (!result.issues.includes(issue)) { + result.issues.push(issue); + for (const key of suggestionKeysFor(issue)) { + if (!result.suggestionKeys.includes(key)) { + result.suggestionKeys.push(key); + } + } + } + }; + + if (!rnode || typeof rnode.getFirmwareVersion !== "function") { + addIssue(ISSUE_DETECT_FAILED); + return result; + } + + try { + result.firmwareVersion = await withTimeout(rnode.getFirmwareVersion(), timeoutMs, "READ_TIMEOUT"); + } catch (err) { + if (err?.code === "READ_TIMEOUT") { + addIssue(ISSUE_READ_TIMEOUT); + } else { + addIssue(ISSUE_NO_FIRMWARE_VERSION); + } + } + + if (typeof rnode.getPlatform === "function") { + try { + result.platform = await withTimeout(rnode.getPlatform(), timeoutMs, "READ_TIMEOUT"); + } catch { + // platform isn't critical for diagnostics + } + } + if (typeof rnode.getBoard === "function") { + try { + result.board = await withTimeout(rnode.getBoard(), timeoutMs, "READ_TIMEOUT"); + } catch { + // ignore + } + } + if (typeof rnode.getMcu === "function") { + try { + result.mcu = await withTimeout(rnode.getMcu(), timeoutMs, "READ_TIMEOUT"); + } catch { + // ignore + } + } + + let romDetails = null; + if (typeof rnode.getRomAsObject === "function") { + try { + const rom = await withTimeout(rnode.getRomAsObject(), timeoutMs, "READ_TIMEOUT"); + result.eepromBytes = Array.isArray(rom?.eeprom) ? Array.from(rom.eeprom) : null; + try { + romDetails = typeof rom?.parse === "function" ? rom.parse() : null; + } catch { + romDetails = null; + } + result.romDetails = romDetails; + result.isProvisioned = Boolean(romDetails?.is_provisioned); + } catch { + // ignore: handled by isProvisioned=false / not_provisioned issue below + } + } + + if (!result.isProvisioned) { + addIssue(ISSUE_NOT_PROVISIONED); + } + + if ( + result.isProvisioned && + options.expectedProductId !== undefined && + romDetails && + romDetails.product !== options.expectedProductId + ) { + addIssue(ISSUE_PRODUCT_MISMATCH); + } + + if (typeof rnode.getFirmwareHash === "function") { + try { + result.firmwareHash = await withTimeout(rnode.getFirmwareHash(), timeoutMs, "READ_TIMEOUT"); + } catch { + // ignore: hash retrieval can fail on un-provisioned units + } + } + if (typeof rnode.getTargetFirmwareHash === "function") { + try { + result.targetFirmwareHash = await withTimeout(rnode.getTargetFirmwareHash(), timeoutMs, "READ_TIMEOUT"); + } catch { + // ignore + } + } + + if (result.isProvisioned) { + if ( + !Array.isArray(result.targetFirmwareHash) || + result.targetFirmwareHash.length === 0 || + result.targetFirmwareHash.every((b) => (b & 0xff) === 0) + ) { + addIssue(ISSUE_FIRMWARE_HASH_MISSING); + } else if ( + Array.isArray(result.firmwareHash) && + result.firmwareHash.length > 0 && + !safeArrayEqual(result.firmwareHash, result.targetFirmwareHash) + ) { + addIssue(ISSUE_FIRMWARE_HASH_MISMATCH); + } + } + + result.summary = { + firmware_version: result.firmwareVersion, + platform: result.platform, + board: result.board, + mcu: result.mcu, + is_provisioned: result.isProvisioned, + product: romDetails?.product ?? null, + model: romDetails?.model ?? null, + hardware_revision: romDetails?.hardware_revision ?? null, + serial_number: romDetails?.serial_number ?? null, + firmware_hash: result.firmwareHash ? RNodeUtils.bytesToHex(result.firmwareHash) : null, + target_firmware_hash: result.targetFirmwareHash ? RNodeUtils.bytesToHex(result.targetFirmwareHash) : null, + }; + + return result; +} + +/** + * Compare a model id from products.js against the EEPROM to see whether the + * selected product/model in the UI matches the device the user just plugged + * in. Useful as a guardrail before flashing. + */ +export function evaluateProductMatch(romDetails, expected) { + if (!romDetails || !expected) { + return { matches: false, reason: "missing_data" }; + } + if (!romDetails.is_provisioned) { + return { matches: false, reason: "not_provisioned" }; + } + if (expected.productId !== undefined && romDetails.product !== expected.productId) { + return { matches: false, reason: "product_mismatch" }; + } + if (expected.modelId !== undefined && romDetails.model !== expected.modelId) { + return { matches: false, reason: "model_mismatch" }; + } + return { matches: true, reason: null }; +} + +export default { diagnose, evaluateProductMatch }; diff --git a/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js b/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js new file mode 100644 index 0000000..95975fd --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js @@ -0,0 +1,218 @@ +import Transport from "./Transport.js"; + +/** + * Bluetooth LE transport for RNode using the Nordic UART Service (NUS). + * + * RNode firmware exposes a serial-over-BLE channel using the standard NUS + * UUIDs (https://infocenter.nordicsemi.com/topic/sdk_nrf5_v17.0.2/ble_sdk_app_nus_eval.html). + * The transport adapts that GATT contract to the Web Serial-style readable/ + * writable streams expected by RNode.js. + * + * Notes: + * - BLE is suitable for management commands, EEPROM operations, TNC + * configuration and bluetooth control. It is NOT usable for ESP32 or + * nRF52 bootloader flashing because those bootloaders speak UART only. + * - Web Bluetooth requires a secure context (https or localhost). + * - The MTU is small (~20 bytes by default), so writes are chunked. + */ + +export const NUS_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; +export const NUS_RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; +export const NUS_TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; + +const DEFAULT_WRITE_CHUNK_SIZE = 20; + +export default class BluetoothTransport extends Transport { + /** + * @param {BluetoothDevice} device + * @param {{ writeChunkSize?: number, env?: object }} [options] + */ + constructor(device, options = {}) { + super("bluetooth"); + if (!device) { + throw new Error("BluetoothTransport requires a BluetoothDevice"); + } + this.device = device; + this.writeChunkSize = options.writeChunkSize || DEFAULT_WRITE_CHUNK_SIZE; + this.env = options.env || (typeof window !== "undefined" ? window : globalThis); + this.gattServer = null; + this.rxCharacteristic = null; + this.txCharacteristic = null; + this._notifyHandler = null; + this._readableController = null; + this._disconnectedHandler = null; + } + + /** + * Trigger a chooser dialog for a NUS-capable RNode. + * Returns a connected BluetoothTransport (with .open() still required to + * attach streams). + */ + static async request(options = {}) { + const env = options.env || (typeof window !== "undefined" ? window : globalThis); + if (!env.navigator?.bluetooth) { + const err = new Error("web_bluetooth_unavailable"); + err.code = "WEB_BLUETOOTH_UNAVAILABLE"; + throw err; + } + let device; + try { + device = await env.navigator.bluetooth.requestDevice({ + filters: options.filters || [{ services: [NUS_SERVICE_UUID] }], + optionalServices: [NUS_SERVICE_UUID, ...(options.optionalServices || [])], + }); + } catch (cause) { + const message = cause?.message || String(cause); + if (cause?.name === "NotFoundError" || /User cancelled/i.test(message)) { + const err = new Error("no_device_selected"); + err.code = "NO_DEVICE_SELECTED"; + err.cause = cause; + throw err; + } + const err = new Error(message); + err.code = "DEVICE_REQUEST_FAILED"; + err.cause = cause; + throw err; + } + return new BluetoothTransport(device, { env }); + } + + async open(_opts = {}) { + if (!this.device.gatt) { + const err = new Error("bluetooth_gatt_unavailable"); + err.code = "GATT_UNAVAILABLE"; + throw err; + } + + this.gattServer = await this.device.gatt.connect(); + + let service; + try { + service = await this.gattServer.getPrimaryService(NUS_SERVICE_UUID); + } catch (cause) { + const err = new Error("nus_service_not_found"); + err.code = "NUS_SERVICE_NOT_FOUND"; + err.cause = cause; + await this._safeDisconnect(); + throw err; + } + + try { + this.rxCharacteristic = await service.getCharacteristic(NUS_RX_CHARACTERISTIC_UUID); + this.txCharacteristic = await service.getCharacteristic(NUS_TX_CHARACTERISTIC_UUID); + } catch (cause) { + const err = new Error("nus_characteristics_missing"); + err.code = "NUS_CHARACTERISTICS_MISSING"; + err.cause = cause; + await this._safeDisconnect(); + throw err; + } + + await this.txCharacteristic.startNotifications(); + + this._notifyHandler = (event) => { + const target = event.target; + const value = target?.value; + if (!value) { + return; + } + const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + if (this._readableController) { + try { + this._readableController.enqueue(bytes); + } catch { + // controller already closed + } + } + }; + this.txCharacteristic.addEventListener("characteristicvaluechanged", this._notifyHandler); + + this._disconnectedHandler = () => { + if (this._readableController) { + try { + this._readableController.close(); + } catch { + // ignore + } + } + }; + this.device.addEventListener("gattserverdisconnected", this._disconnectedHandler); + + const transport = this; + this.readable = new ReadableStream({ + start(controller) { + transport._readableController = controller; + }, + cancel() { + transport._readableController = null; + }, + }); + + const chunkSize = this.writeChunkSize; + const writeChar = this.rxCharacteristic; + this.writable = new WritableStream({ + async write(chunk) { + const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); + let offset = 0; + while (offset < bytes.byteLength) { + const slice = bytes.subarray(offset, offset + chunkSize); + if (typeof writeChar.writeValueWithoutResponse === "function") { + await writeChar.writeValueWithoutResponse(slice); + } else { + await writeChar.writeValue(slice); + } + offset += chunkSize; + } + }, + }); + } + + async close() { + if (this.txCharacteristic && this._notifyHandler) { + try { + this.txCharacteristic.removeEventListener("characteristicvaluechanged", this._notifyHandler); + } catch { + // ignore + } + try { + await this.txCharacteristic.stopNotifications(); + } catch { + // ignore + } + } + if (this.device && this._disconnectedHandler) { + try { + this.device.removeEventListener("gattserverdisconnected", this._disconnectedHandler); + } catch { + // ignore + } + } + await this._safeDisconnect(); + this.readable = null; + this.writable = null; + this.rxCharacteristic = null; + this.txCharacteristic = null; + this._notifyHandler = null; + this._disconnectedHandler = null; + this._readableController = null; + } + + async _safeDisconnect() { + try { + if (this.gattServer && this.gattServer.connected) { + this.gattServer.disconnect(); + } + } catch { + // ignore + } + this.gattServer = null; + } + + canManageDevice() { + return true; + } + + description() { + return "bluetooth-le"; + } +} diff --git a/meshchatx/src/frontend/js/rnode/transports/SerialTransport.js b/meshchatx/src/frontend/js/rnode/transports/SerialTransport.js new file mode 100644 index 0000000..4bbdb56 --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/transports/SerialTransport.js @@ -0,0 +1,104 @@ +import Transport from "./Transport.js"; + +/** + * Web Serial transport. + * + * Wraps an underlying SerialPort (either the native navigator.serial port or + * the web-serial-polyfill port that bridges WebUSB devices). Most of the + * real work is delegated to the wrapped port; the wrapper is mainly here so + * the rest of the codebase has a single uniform API and so error reporting + * surface area is centralised. + */ +export default class SerialTransport extends Transport { + /** + * @param {SerialPort} port wrapped serial port + * @param {{ polyfilled?: boolean, env?: object }} [options] + */ + constructor(port, options = {}) { + super("serial"); + if (!port) { + throw new Error("SerialTransport requires a SerialPort instance"); + } + this.port = port; + this.polyfilled = Boolean(options.polyfilled); + this.env = options.env || (typeof window !== "undefined" ? window : globalThis); + this.opened = false; + } + + /** + * Request a port from the user via navigator.serial.requestPort. Returns + * a SerialTransport wrapper or throws a descriptive error. + * + * @param {{ filters?: Array, env?: object }} [options] + */ + static async request(options = {}) { + const env = options.env || (typeof window !== "undefined" ? window : globalThis); + if (!env.navigator?.serial) { + const err = new Error("web_serial_unavailable"); + err.code = "WEB_SERIAL_UNAVAILABLE"; + throw err; + } + let port; + try { + port = await env.navigator.serial.requestPort({ + filters: options.filters || [], + }); + } catch (cause) { + const message = cause?.message || String(cause); + if (cause?.name === "NotFoundError" || /No port selected/i.test(message)) { + const err = new Error("no_port_selected"); + err.code = "NO_PORT_SELECTED"; + err.cause = cause; + throw err; + } + const err = new Error(message); + err.code = "PORT_REQUEST_FAILED"; + err.cause = cause; + throw err; + } + const polyfilled = Boolean(env.serial && env.navigator.serial === env.serial); + return new SerialTransport(port, { polyfilled, env }); + } + + async open(opts = {}) { + const baudRate = typeof opts.baudRate === "number" ? opts.baudRate : 115200; + await this.port.open({ baudRate }); + this.readable = this.port.readable; + this.writable = this.port.writable; + this.opened = true; + } + + async close() { + this.opened = false; + try { + if (typeof this.port.close === "function") { + await this.port.close(); + } + } finally { + this.readable = null; + this.writable = null; + } + } + + async setSignals(signals) { + if (typeof this.port.setSignals === "function") { + await this.port.setSignals(signals); + } + } + + canFlashEsp32() { + return true; + } + + canFlashNrf52() { + return true; + } + + canManageDevice() { + return true; + } + + description() { + return this.polyfilled ? "serial-polyfill" : "serial"; + } +} diff --git a/meshchatx/src/frontend/js/rnode/transports/Transport.js b/meshchatx/src/frontend/js/rnode/transports/Transport.js new file mode 100644 index 0000000..2e60353 --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/transports/Transport.js @@ -0,0 +1,56 @@ +/** + * Abstract transport contract used by RNode flasher. + * + * Concrete transports (Web Serial, Web Bluetooth NUS, WiFi OTA, ...) expose + * the same shape as a Web Serial port: + * + * - readable: ReadableStream + * - writable: WritableStream + * - open(opts): Promise + * - close(): Promise + * + * This lets RNode.js and Nrf52DfuFlasher.js stay transport-agnostic. + * + * Concrete classes should also expose: + * - kind: short string identifier ("serial", "bluetooth", "wifi") + * - canFlashEsp32(): boolean + * - canFlashNrf52(): boolean + * - canManageDevice(): boolean + * - description(): short user-facing label + */ + +export default class Transport { + constructor(kind) { + this.kind = kind; + this.readable = null; + this.writable = null; + } + + async open(_opts = {}) { + throw new Error(`${this.kind} transport: open() not implemented`); + } + + async close() { + throw new Error(`${this.kind} transport: close() not implemented`); + } + + canFlashEsp32() { + return false; + } + + canFlashNrf52() { + return false; + } + + canManageDevice() { + return false; + } + + canOtaFlash() { + return false; + } + + description() { + return this.kind; + } +} diff --git a/meshchatx/src/frontend/js/rnode/transports/WifiTransport.js b/meshchatx/src/frontend/js/rnode/transports/WifiTransport.js new file mode 100644 index 0000000..a3953ff --- /dev/null +++ b/meshchatx/src/frontend/js/rnode/transports/WifiTransport.js @@ -0,0 +1,123 @@ +import Transport from "./Transport.js"; + +/** + * WiFi/OTA transport for RNode firmware over the device HTTP update endpoint. + * + * Most RNode-derived ESP32 firmwares ship a small HTTP server that exposes + * /update for in-place flashing of the main application image. This transport + * does not provide a serial-style readable/writable pair; instead it exposes + * upload(blob, onProgress) which performs an XMLHttpRequest multipart POST + * with progress events and timeout handling. + */ + +const DEFAULT_TIMEOUT_MS = 120000; +const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$/; +const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; + +export default class WifiTransport extends Transport { + /** + * @param {string} ipAddressOrHost + * @param {{ timeoutMs?: number, env?: object, scheme?: string }} [options] + */ + constructor(ipAddressOrHost, options = {}) { + super("wifi"); + if (!WifiTransport.isValidHost(ipAddressOrHost)) { + const err = new Error("invalid_host"); + err.code = "INVALID_HOST"; + throw err; + } + this.host = ipAddressOrHost; + this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS; + this.env = options.env || (typeof window !== "undefined" ? window : globalThis); + this.scheme = options.scheme || "http"; + } + + static isValidHost(value) { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 253) { + return false; + } + return IPV4_RE.test(trimmed) || HOSTNAME_RE.test(trimmed); + } + + async open() { + // No persistent stream; OTA upload is one-shot. Kept to keep API symmetric. + this.opened = true; + } + + async close() { + this.opened = false; + } + + /** + * Upload a firmware blob to /update on the configured device. + * + * @param {Blob} blob raw firmware payload + * @param {(percentage: number) => void} [onProgress] + */ + async upload(blob, onProgress) { + if (!blob) { + const err = new Error("no_payload"); + err.code = "NO_PAYLOAD"; + throw err; + } + const Xhr = this.env.XMLHttpRequest; + if (!Xhr) { + const err = new Error("xhr_unavailable"); + err.code = "XHR_UNAVAILABLE"; + throw err; + } + return new Promise((resolve, reject) => { + const xhr = new Xhr(); + const url = `${this.scheme}://${this.host}/update`; + xhr.open("POST", url, true); + xhr.timeout = this.timeoutMs; + + if (xhr.upload && typeof onProgress === "function") { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) { + const percentage = Math.floor((event.loaded / event.total) * 100); + onProgress(percentage); + } + }; + } + + xhr.ontimeout = () => { + const err = new Error("upload_timeout"); + err.code = "UPLOAD_TIMEOUT"; + reject(err); + }; + xhr.onerror = () => { + const err = new Error("network_error"); + err.code = "NETWORK_ERROR"; + reject(err); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve({ status: xhr.status, body: xhr.responseText }); + return; + } + const err = new Error(`http_${xhr.status}`); + err.code = "HTTP_ERROR"; + err.status = xhr.status; + err.body = xhr.responseText; + reject(err); + }; + + const formData = new this.env.FormData(); + formData.append("update", blob, "firmware.bin"); + xhr.send(formData); + }); + } + + canOtaFlash() { + return true; + } + + description() { + return `wifi://${this.host}`; + } +}