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 @@
-
-
-
-
- {{ $t("sticker_editor.cancel") }}
+ {{ $t("common.cancel") }}
-
-
-
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}`;
+ }
+}