diff --git a/.vscode/settings.json b/.vscode/settings.json index aa09339f..3b4797be 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,4 +6,5 @@ "search.exclude": { "**/i18n/locales/*-*/**": true, }, + "vitest.workspaceConfig": "vitest.config.ts", } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5a487415..751f4a6c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,7 +10,12 @@ interface DebugLog { data: string; } -export type DeviceOutput = Packet | DebugLog; +interface StatusEvent { + type: "status"; + data: { status: DeviceStatusEnum; reason?: string }; +} + +export type DeviceOutput = Packet | DebugLog | StatusEvent; export interface Transport { toDevice: WritableStream; @@ -101,6 +106,7 @@ export enum Emitter { RemoveNodeByNum = 32, SetCannedMessages = 33, Disconnect = 34, + ConnectionStatus = 35, } export interface LogEvent { diff --git a/packages/core/src/utils/transform/decodePacket.ts b/packages/core/src/utils/transform/decodePacket.ts index 94f0e54d..d44e52cf 100644 --- a/packages/core/src/utils/transform/decodePacket.ts +++ b/packages/core/src/utils/transform/decodePacket.ts @@ -7,6 +7,19 @@ export const decodePacket = (device: MeshDevice) => new WritableStream({ write(chunk) { switch (chunk.type) { + case "status": { + const { status, reason } = chunk.data as { + status: Types.DeviceStatusEnum; + reason?: string; + }; + + device.updateDeviceStatus(status); + device.log.info( + Types.Emitter[Types.Emitter.ConnectionStatus], + `🔗 ${Types.DeviceStatusEnum[status]} ${reason ? `(${reason})` : ""}`, + ); + break; + } case "debug": { break; } diff --git a/packages/transport-http/src/transport.test.ts b/packages/transport-http/src/transport.test.ts new file mode 100644 index 00000000..f1712964 --- /dev/null +++ b/packages/transport-http/src/transport.test.ts @@ -0,0 +1,229 @@ +import { describe, vi, expect, it, beforeEach, afterEach, type MockInstance } from "vitest"; +import { runTransportContract } from "../../../tests/utils/transportContract"; +import { TransportHTTP } from "./transport"; + +let abortTimeoutSpy: MockInstance | undefined; +beforeEach(() => { + abortTimeoutSpy = vi.spyOn( + globalThis.AbortSignal as unknown as { timeout(ms: number): AbortSignal }, + "timeout", + ).mockImplementation((ms: number) => { + const ctrl = new AbortController(); + const abort = () => + ctrl.abort(new DOMException("Timeout reached", "TimeoutError")); + // Uses setTimeout so vi.useFakeTimers() can fast-forward it + setTimeout(abort, ms); + return ctrl.signal; + }); +}); + +afterEach(() => { + abortTimeoutSpy?.mockRestore(); +}); + +function stubFetch() { + const inbox: Uint8Array[] = []; + let lastWritten: ArrayBuffer | undefined; + + let forceNextReadToHang = false; + let forceNextReadToReturn500 = false; + + function makeAbortAwareHang(signal?: AbortSignal): Promise { + return new Promise((_, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal?.aborted) { + abort(); + return; + } + if (signal) { + signal.addEventListener("abort", abort, { once: true }); + } + }); + } + + const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + + if (url.includes("/api/v1/toradio") && method === "OPTIONS") { + return { ok: true, status: 204 } as Response; + } + + if (url.includes("/api/v1/toradio") && method === "PUT") { + lastWritten = init?.body as ArrayBuffer; + return { ok: true, status: 200 } as Response; + } + + if (url.includes("/api/v1/fromradio") && method === "GET") { + if (forceNextReadToHang) { + forceNextReadToHang = false; + return makeAbortAwareHang(init?.signal ?? undefined); + } + + if (forceNextReadToReturn500) { + forceNextReadToReturn500 = false; + return { + ok: false, + status: 500, + arrayBuffer: async () => new ArrayBuffer(0), + } as Response; + } + + const next = inbox.shift() ?? new Uint8Array(); + return { + ok: true, + status: 200, + arrayBuffer: async () => next.buffer, + } as Response; + } + + return { ok: true, status: 200 } as Response; + }); + + vi.stubGlobal("fetch", mockFetch); + + return { + pushIncoming: (u8: Uint8Array) => inbox.push(u8), + assertLastWritten: (u8: Uint8Array) => { + const got = new Uint8Array(lastWritten || new ArrayBuffer(0)); + expect(got).toEqual(u8); + }, + forceReadErrorOnce: () => { + forceNextReadToReturn500 = true; + }, + forceReadTimeoutOnce: () => { + forceNextReadToHang = true; + }, + getMock: () => mockFetch, + cleanup: () => vi.unstubAllGlobals(), + }; +} + +async function tickNextTimer() { + try { + await vi.advanceTimersToNextTimerAsync(); + } catch { + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("TransportHTTP (contract)", () => { + runTransportContract({ + name: "TransportHTTP", + setup: () => { + vi.useFakeTimers(); + }, + teardown: () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }, + create: async () => { + (globalThis as unknown as { __http: ReturnType }).__http = stubFetch(); + const transport = await TransportHTTP.create("127.0.0.1:80", false); + await tickNextTimer(); + return transport; + }, + pushIncoming: async (bytes) => { + (globalThis as unknown as { __http: ReturnType }).__http.pushIncoming(bytes); + await tickNextTimer(); + }, + assertLastWritten: (bytes) => { + (globalThis as unknown as { __http: ReturnType }).__http.assertLastWritten(bytes); + }, + triggerDisconnect: async () => { + (globalThis as unknown as { __http: ReturnType }).__http.forceReadErrorOnce(); + await tickNextTimer(); + }, + }); +}); + +describe("TransportHTTP (extras)", () => { + let httpStub: ReturnType | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + httpStub?.cleanup(); + httpStub = undefined; + }); + + async function createTransport(): Promise { + httpStub = stubFetch(); + const transport = await TransportHTTP.create("127.0.0.1:80", false); + await tickNextTimer(); + return transport; + } + + async function advanceOnePoll() { + await tickNextTimer(); + } + + it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { + const transport = await createTransport(); + const reader = transport.fromDevice.getReader(); + + httpStub!.forceReadTimeoutOnce(); + + await tickNextTimer(); + await vi.advanceTimersByTimeAsync(8000); + + let sawReadTimeout = false; + for (let i = 0; i < 6; i++) { + const { value } = await reader.read(); + if (value?.type === "status" && value.data.reason === "read-timeout") { + sawReadTimeout = true; + break; + } + } + expect(sawReadTimeout).toBe(true); + + reader.releaseLock(); + await transport.disconnect(); + }); + + it("stops polling after disconnect()", async () => { + const transport = await createTransport(); + + const fetchMock = httpStub!.getMock(); + const callsBeforeDisconnect = fetchMock.mock.calls.length; + + await transport.disconnect(); + + await advanceOnePoll(); + await vi.runOnlyPendingTimersAsync(); + + const callsAfterDisconnect = fetchMock.mock.calls.length; + expect(callsAfterDisconnect).toBe(callsBeforeDisconnect); + }); + + it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { + const transport = await createTransport(); + const reader = transport.fromDevice.getReader(); + + httpStub!.forceReadTimeoutOnce(); + + await vi.advanceTimersToNextTimerAsync(); + + await vi.advanceTimersByTimeAsync(8000); + + await Promise.resolve(); + await Promise.resolve(); + + let sawReadTimeout = false; + for (let i = 0; i < 6; i++) { + const { value } = await reader.read(); + if (value?.type === "status" && value.data.reason === "read-timeout") { + sawReadTimeout = true; + break; + } + } + expect(sawReadTimeout).toBe(true); + + reader.releaseLock(); + await transport.disconnect(); + }); +}); diff --git a/packages/transport-http/src/transport.ts b/packages/transport-http/src/transport.ts index 42dd6221..dae3baaa 100644 --- a/packages/transport-http/src/transport.ts +++ b/packages/transport-http/src/transport.ts @@ -1,14 +1,49 @@ -import type { Types } from "@meshtastic/core"; +import { Types } from "@meshtastic/core"; +const FETCH_INTERVAL_MS = 3000; +const READ_TIMEOUT_MS = 7000; +const WRITE_TIMEOUT_MS = 4000; + +function toArrayBuffer(uint8array: Uint8Array): ArrayBuffer { + if ( + uint8array.buffer instanceof ArrayBuffer && + uint8array.byteOffset === 0 && + uint8array.byteLength === uint8array.buffer.byteLength + ) { + return uint8array.buffer; + } + return uint8array.slice().buffer; +} + +/** + * Provides HTTP(S) transport for Meshtastic devices. + * + * Implements {@link Types.Transport} using the device's HTTP API. + * Polls `/api/v1/fromradio` for incoming packets and writes to `/api/v1/toradio`. + */ export class TransportHTTP implements Types.Transport { private _toDevice: WritableStream; private _fromDevice: ReadableStream; + private fromDeviceController?: ReadableStreamDefaultController; + private url: string; private receiveBatchRequests: boolean; private fetchInterval: number; private fetching: boolean; private interval: ReturnType | undefined; + private inflightReadController?: AbortController; + + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + private closingByUser = false; + + /** + * Probe the device and return a connected HTTP transport. + * + * @param address Hostname or IP address (with optional port). + * @param tls Use HTTPS if true, HTTP otherwise. + */ public static async create( address: string, tls?: boolean, @@ -17,97 +52,192 @@ export class TransportHTTP implements Types.Transport { await fetch(`${connectionUrl}/api/v1/toradio`, { method: "OPTIONS", }); - await Promise.resolve(); return new TransportHTTP(connectionUrl); } + /** + * Construct a new HTTP transport for the given device URL. + * + * @param url Base URL of the device (`http://host:port` or `https://host:port`). + */ constructor(url: string) { this.url = url; this.receiveBatchRequests = false; - this.fetchInterval = 3000; + this.fetchInterval = FETCH_INTERVAL_MS; this.fetching = false; this._toDevice = new WritableStream({ write: async (chunk) => { - await this.writeToRadio(chunk); + try { + await this.writeToRadio(chunk); + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + this.isTimeoutOrAbort(error) ? "write-timeout" : "write-error", + ); + } + throw error; + } }, }); - let controller: ReadableStreamDefaultController; - this._fromDevice = new ReadableStream({ start: (ctrl) => { - controller = ctrl; + this.fromDeviceController = ctrl; + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); + + // Start polling immediately + void this.safePoll(); + this.interval = setInterval( + () => void this.safePoll(), + this.fetchInterval, + ); + }, + cancel: () => { + if (this.interval) { + clearInterval(this.interval); + } + this.interval = undefined; }, }); - - this.interval = setInterval(async () => { - if (this.fetching) { - // We still have the previous request open - return; - } - this.fetching = true; - try { - await this.readFromRadio(controller); - } catch { - // TODO: Emit disconnection events for certain types of errors - } - this.fetching = false; - }, this.fetchInterval); } - private async readFromRadio( - controller: ReadableStreamDefaultController, - ): Promise { + /** Poll `/api/v1/fromradio` and enqueue incoming packets. */ + private async readFromRadio(): Promise { let readBuffer = new ArrayBuffer(1); + while (readBuffer.byteLength > 0) { - const response = await fetch( - `${this.url}/api/v1/fromradio?all=${ - this.receiveBatchRequests ? "true" : "false" - }`, - { - method: "GET", - headers: { - Accept: "application/x-protobuf", + const inflight = new AbortController(); + this.inflightReadController = inflight; + + const signal = AbortSignal.any([ + inflight.signal, + AbortSignal.timeout(READ_TIMEOUT_MS), + ]); + + try { + const response = await fetch( + `${this.url}/api/v1/fromradio?all=${this.receiveBatchRequests ? "true" : "false"}`, + { + method: "GET", + headers: { Accept: "application/x-protobuf" }, + signal, }, - }, - ); + ); + if (!response.ok) { + throw new Error( + `fromradio ${response.status} ${response.statusText}`, + ); + } + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); - readBuffer = await response.arrayBuffer(); + readBuffer = await response.arrayBuffer(); - if (readBuffer.byteLength > 0) { - controller.enqueue({ - type: "packet", - data: new Uint8Array(readBuffer), - }); + if (readBuffer.byteLength > 0) { + this.fromDeviceController?.enqueue({ + type: "packet", + data: new Uint8Array(readBuffer), + }); + } + } finally { + this.inflightReadController = undefined; } } } + /** Write a protobuf-encoded request to `/api/v1/toradio`. */ private async writeToRadio(data: Uint8Array): Promise { - await fetch(`${this.url}/api/v1/toradio`, { - method: "PUT", - headers: { - "Content-Type": "application/x-protobuf", - }, - body: data, - }); + try { + const response = await fetch(`${this.url}/api/v1/toradio`, { + method: "PUT", + headers: { "Content-Type": "application/x-protobuf" }, + body: toArrayBuffer(data), + signal: AbortSignal.timeout(WRITE_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`toradio ${response.status} ${response.statusText}`); + } + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + this.isTimeoutOrAbort(error) ? "write-timeout" : "write-error", + ); + } + throw error; + } } + /** Writable stream of bytes to the device. */ get toDevice(): WritableStream { return this._toDevice; } + /** Readable stream of {@link Types.DeviceOutput} from the device. */ get fromDevice(): ReadableStream { return this._fromDevice; } + /** + * Stop polling and emit `DeviceDisconnected("user")`. + */ disconnect(): Promise { - this.fetching = false; + this.closingByUser = true; + if (this.interval) { clearInterval(this.interval); } this.interval = undefined; + this.fetching = false; + + try { + this.inflightReadController?.abort(); + } catch {} + this.inflightReadController = undefined; + + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + return Promise.resolve(); } + + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); + } + + private isTimeoutOrAbort(err: unknown): boolean { + return ( + (err instanceof DOMException && + (err.name === "AbortError" || err.name === "TimeoutError")) || + (err instanceof Error && + (err.name === "AbortError" || err.name === "TimeoutError")) + ); + } + + private async safePoll(): Promise { + if (this.fetching) { + return; + } + this.fetching = true; + try { + await this.readFromRadio(); + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + this.isTimeoutOrAbort(error) ? "read-timeout" : "read-error", + ); + } + } finally { + this.fetching = false; + } + } } diff --git a/packages/transport-node-serial/src/transport.test.ts b/packages/transport-node-serial/src/transport.test.ts new file mode 100644 index 00000000..ef4e4a4e --- /dev/null +++ b/packages/transport-node-serial/src/transport.test.ts @@ -0,0 +1,187 @@ +import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; +import { Duplex } from "node:stream"; +import type { SerialPort } from "serialport"; +import { Types, Utils } from "@meshtastic/core"; +import { runTransportContract } from "../../../tests/utils/transportContract"; +import { TransportNodeSerial } from "./transport"; + +function isStatusEvent( + output: Types.DeviceOutput | undefined, +): output is Extract { + return output !== undefined && output.type === "status"; +} + +class FakeSerialPort extends Duplex { + public lastWritten: Uint8Array | undefined; + + constructor() { + super({ objectMode: false }); + } + + _read() {} + + _write( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + this.lastWritten = new Uint8Array( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ); + callback(); + } + + pushIncoming(data: Uint8Array) { + const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + this.push(buf); + } + + emitErrorOnce(message = "simulated serial error") { + this.emit("error", new Error(message)); + } + + emitClose() { + this.emit("close"); + } + + close() { + this.destroy(); + this.emit("close"); + } +} + +function stubCoreTransforms() { + const toDevice = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + + const fromDeviceFactory = () => + new TransformStream({ + transform(chunk, controller) { + controller.enqueue({ type: "packet", data: chunk }); + }, + }); + + // Utils.toDeviceStream is a getter + vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( + toDevice as unknown as typeof Utils.toDeviceStream, + ); + + vi.spyOn(Utils, "fromDeviceStream").mockImplementation( + () => + fromDeviceFactory() as unknown as TransformStream< + Uint8Array, + Types.DeviceOutput + >, + ); + + return { + restore: () => vi.restoreAllMocks(), + }; +} + +describe("TransportNodeSerial (contract)", () => { + let transformsStub: { restore: () => void } | undefined; + + beforeEach(() => { + transformsStub = stubCoreTransforms(); + }); + + afterEach(() => { + transformsStub?.restore(); + }); + + runTransportContract({ + name: "TransportNodeSerial", + setup: () => {}, + teardown: () => { + vi.restoreAllMocks(); + }, + create: async () => { + const fakePort = new FakeSerialPort(); + const transport = new TransportNodeSerial( + fakePort as unknown as SerialPort, + ); + await Promise.resolve(); + (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort = + fakePort; + return transport; + }, + pushIncoming: async (bytes) => { + (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.pushIncoming( + bytes, + ); + await Promise.resolve(); + }, + assertLastWritten: (bytes) => { + const port = + (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort; + expect(port.lastWritten).toBeDefined(); + expect(port.lastWritten).toEqual(bytes); + }, + triggerDisconnect: async () => { + (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.emitErrorOnce( + "test-disconnect", + ); + await Promise.resolve(); + }, + }); +}); + +describe("TransportNodeSerial (extras)", () => { + let transformsStub: { restore: () => void } | undefined; + + beforeEach(() => { + transformsStub = stubCoreTransforms(); + }); + + afterEach(() => { + transformsStub?.restore(); + }); + + it("emits DeviceDisconnected with reason 'port-closed' on close event", async () => { + const fakePort = new FakeSerialPort(); + const transport = new TransportNodeSerial( + fakePort as unknown as SerialPort, + ); + const reader = transport.fromDevice.getReader(); + + await Promise.resolve(); + + const first = await reader.read(); + expect(isStatusEvent(first.value)).toBe(true); + if (isStatusEvent(first.value)) { + expect(first.value.data.status).toBe( + Types.DeviceStatusEnum.DeviceConnecting, + ); + } + + const second = await reader.read(); + expect(isStatusEvent(second.value)).toBe(true); + if (isStatusEvent(second.value)) { + expect(second.value.data.status).toBe( + Types.DeviceStatusEnum.DeviceConnected, + ); + } + + fakePort.emitClose(); + await Promise.resolve(); + + let sawClosed = false; + for (let i = 0; i < 6; i++) { + const { value } = await reader.read(); + if (isStatusEvent(value) && value.data.reason === "port-closed") { + sawClosed = true; + break; + } + } + expect(sawClosed).toBe(true); + + reader.releaseLock(); + await transport.disconnect(); + }); +}); diff --git a/packages/transport-node-serial/src/transport.ts b/packages/transport-node-serial/src/transport.ts index 56947bb6..fc5df1a7 100644 --- a/packages/transport-node-serial/src/transport.ts +++ b/packages/transport-node-serial/src/transport.ts @@ -1,17 +1,29 @@ import { Readable, Writable } from "node:stream"; -import type { Types } from "@meshtastic/core"; -import { Utils } from "@meshtastic/core"; +import { Types, Utils } from "@meshtastic/core"; import { SerialPort } from "serialport"; +/** + * Node.js Serial transport for Meshtastic. + * + * Implements {@link Types.Transport} on top of a Node `SerialPort`. + * Use {@link TransportNodeSerial.create} for a convenient factory, or + * `new TransportNodeSerial(port)` if you already have an open port. + */ export class TransportNodeSerial implements Types.Transport { private readonly _toDevice: WritableStream; private readonly _fromDevice: ReadableStream; + private fromDeviceController?: ReadableStreamDefaultController; private port: SerialPort | undefined; + private pipePromise?: Promise; + private abortController: AbortController; + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + private closingByUser = false; /** * Creates and connects a new TransportNode instance. * @param path - Path to the serial device - * @param baudRate - The port number for the TCP connection (defaults to 4403). + * @param baudRate - Baud rate for the serial connection (default is 115200). * @returns A promise that resolves with a connected TransportNode instance. */ public static create( @@ -46,23 +58,78 @@ export class TransportNodeSerial implements Types.Transport { this.port = port; this.port.on("error", (err) => { console.error("Serial port connection error:", err); + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "port-error"); + }); + this.port.on("close", () => { + if (this.closingByUser) { + return; + } + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "port-closed"); }); const fromDeviceSource = Readable.toWeb(port) as ReadableStream; - this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); + const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); + + this.abortController = new AbortController(); + const controller = this.abortController; + + this._fromDevice = new ReadableStream({ + start: async (ctrl) => { + this.fromDeviceController = ctrl; + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); + + const reader = transformed.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + ctrl.enqueue(value); + } + ctrl.close(); + } catch (error) { + if (this.closingByUser) { + ctrl.close(); // graceful EOF on user + } else { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "read-error", + ); + ctrl.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + try { + await transformed.cancel(); + } catch {} + } finally { + reader.releaseLock(); + } + }, + }); // Stream for data going FROM the application TO the Meshtastic device. - const toDeviceTransform = Utils.toDeviceStream; - this._toDevice = toDeviceTransform.writable; - - // The readable end of the transform is then piped to the Node.js SerialPort connection. - // A similar assertion is needed here because `Writable.toWeb` also returns - // a generically typed stream (`WritableStream`). - toDeviceTransform.readable - .pipeTo(Writable.toWeb(port) as WritableStream) - .catch((err) => { - console.error("Error piping data to serial port:", err); - this.port.close(err as Error); + this._toDevice = Utils.toDeviceStream.writable; + + this.pipePromise = Utils.toDeviceStream.readable + .pipeTo(Writable.toWeb(port) as WritableStream, { + signal: controller.signal, + }) + .catch((error) => { + if (controller.signal.aborted || this.closingByUser) { + return; + } + console.error("Error piping data to serial port:", error); + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "write-error", + ); + try { + this.port?.close(); + } catch {} }); } @@ -80,9 +147,35 @@ export class TransportNodeSerial implements Types.Transport { return this._fromDevice; } - disconnect() { - this.port.close(); - this.port = undefined; - return Promise.resolve(); + /** + * Disconnect from the serial port and emit `DeviceDisconnected("user")`. + * Safe to call multiple times. + */ + async disconnect() { + try { + this.closingByUser = true; + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + + this.abortController?.abort(); + await this.pipePromise?.catch(() => {}); + + try { + this.port?.close(); + } catch {} + } finally { + this.port = undefined; + this.closingByUser = false; + } + } + + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); } } diff --git a/packages/transport-node/src/transport.test.ts b/packages/transport-node/src/transport.test.ts new file mode 100644 index 00000000..0e5f7d79 --- /dev/null +++ b/packages/transport-node/src/transport.test.ts @@ -0,0 +1,186 @@ +import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; +import { Duplex } from "node:stream"; +import type { Socket } from "node:net"; +import { runTransportContract } from "../../../tests/utils/transportContract"; +import { TransportNode } from "./transport"; +import { Utils, Types } from "@meshtastic/core"; + +function isStatusEvent( + out: Types.DeviceOutput | undefined, +): out is Extract { + return !!out && (out as any).type === "status"; +} + +class FakeSocket extends Duplex { + public lastWritten: Uint8Array | undefined; + + constructor() { + super({ objectMode: false }); + } + + _read() {} + + _write( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + this.lastWritten = new Uint8Array( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ); + callback(); + } + + pushIncoming(data: Uint8Array) { + const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + this.push(buf); + } + + emitErrorOnce(message = "simulated error") { + this.emit("error", new Error(message)); + } + + emitClose() { + this.emit("close"); + } + + override destroy(error?: Error) { + super.destroy(error); + this.emit("close"); + return this; + } +} + +function stubCoreTransforms() { + const toDevice = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + + const fromDeviceFactory = () => + new TransformStream({ + transform(chunk, controller) { + controller.enqueue({ type: "packet", data: chunk }); + }, + }); + + vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( + toDevice as unknown as typeof Utils.toDeviceStream, + ); + + vi + .spyOn(Utils, "fromDeviceStream") + .mockImplementation( + () => + fromDeviceFactory() as unknown as TransformStream< + Uint8Array, + Types.DeviceOutput + >, + ); + + return { + restore: () => vi.restoreAllMocks(), + }; +} + +describe("TransportNode (contract)", () => { + let transformsStub: { restore: () => void } | undefined; + + beforeEach(() => { + transformsStub = stubCoreTransforms(); + }); + + afterEach(() => { + transformsStub?.restore(); + }); + + runTransportContract({ + name: "TransportNode", + setup: () => {}, + teardown: () => { + vi.restoreAllMocks(); + }, + create: async () => { + const fakeSocket = new FakeSocket(); + const transport = new TransportNode(fakeSocket as unknown as Socket); + await Promise.resolve(); + (globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock = + fakeSocket; + return transport; + }, + pushIncoming: async (bytes) => { + (globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.pushIncoming( + bytes, + ); + await Promise.resolve(); + }, + assertLastWritten: (bytes) => { + const sock = (globalThis as unknown as { __nodeSock: FakeSocket }) + .__nodeSock; + expect(sock.lastWritten).toBeDefined(); + expect(sock.lastWritten).toEqual(bytes); + }, + triggerDisconnect: async () => { + (globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.emitErrorOnce( + "test-disconnect", + ); + await Promise.resolve(); + }, + }); +}); + +describe("TransportNode (extras)", () => { + let transformsStub: { restore: () => void } | undefined; + + beforeEach(() => { + transformsStub = stubCoreTransforms(); + }); + + afterEach(() => { + transformsStub?.restore(); + }); + + it("emits DeviceDisconnected with reason 'socket-closed' on close event", async () => { + const fakeSocket = new FakeSocket(); + const transport = new TransportNode(fakeSocket as unknown as Socket); + const reader = transport.fromDevice.getReader(); + + await Promise.resolve(); + + const first = await reader.read(); + expect(isStatusEvent(first.value)).toBe(true); + if (isStatusEvent(first.value)) { + expect(first.value.data.status).toBe( + Types.DeviceStatusEnum.DeviceConnecting, + ); + } + + const second = await reader.read(); + expect(isStatusEvent(second.value)).toBe(true); + if (isStatusEvent(second.value)) { + expect(second.value.data.status).toBe( + Types.DeviceStatusEnum.DeviceConnected, + ); + } + + fakeSocket.emitClose(); + await Promise.resolve(); + + let sawClosed = false; + for (let i = 0; i < 6; i++) { + const { value } = await reader.read(); + if (isStatusEvent(value) && value.data.reason === "socket-closed") { + sawClosed = true; + break; + } + } + expect(sawClosed).toBe(true); + + reader.releaseLock(); + await transport.disconnect(); + }); + +}); diff --git a/packages/transport-node/src/transport.ts b/packages/transport-node/src/transport.ts index 1a98828d..03713d0b 100644 --- a/packages/transport-node/src/transport.ts +++ b/packages/transport-node/src/transport.ts @@ -1,12 +1,25 @@ import { Socket } from "node:net"; import { Readable, Writable } from "node:stream"; -import type { Types } from "@meshtastic/core"; -import { Utils } from "@meshtastic/core"; +import { Types, Utils } from "@meshtastic/core"; +/** + * Node.js TCP transport for Meshtastic. + * + * Implements {@link Types.Transport} on top of a Node `net.Socket`. + * Use {@link TransportNode.create} to open a new connection, or + * construct directly with an existing socket. + */ export class TransportNode implements Types.Transport { private readonly _toDevice: WritableStream; private readonly _fromDevice: ReadableStream; + private fromDeviceController?: ReadableStreamDefaultController; private socket: Socket | undefined; + private pipePromise?: Promise; + private abortController: AbortController; + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + + private closingByUser = false; /** * Creates and connects a new TransportNode instance. @@ -38,47 +51,131 @@ export class TransportNode implements Types.Transport { */ constructor(connection: Socket) { this.socket = connection; + this.socket.on("error", (err) => { console.error("Socket connection error:", err); + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "socket-error", + ); + } + }); + + this.socket.on("close", () => { + if (this.closingByUser) { + return; // suppress close-derived disconnect in user flow + } + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "socket-closed", + ); }); + this.abortController = new AbortController(); + const abortController = this.abortController; + const fromDeviceSource = Readable.toWeb( connection, ) as ReadableStream; - this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); + const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); + + this._fromDevice = new ReadableStream({ + start: async (ctrl) => { + this.fromDeviceController = ctrl; + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); + + const reader = transformed.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + ctrl.enqueue(value); + } + ctrl.close(); + } catch (error) { + if (this.closingByUser) { + ctrl.close(); + } else { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "read-error", + ); + + ctrl.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + try { + await transformed.cancel(); + } catch {} + } finally { + reader.releaseLock(); + } + }, + }); // Stream for data going FROM the application TO the Meshtastic device. const toDeviceTransform = Utils.toDeviceStream; this._toDevice = toDeviceTransform.writable; - // The readable end of the transform is then piped to the Node.js socket. - // A similar assertion is needed here because `Writable.toWeb` also returns - // a generically typed stream (`WritableStream`). - toDeviceTransform.readable - .pipeTo(Writable.toWeb(connection) as WritableStream) + this.pipePromise = toDeviceTransform.readable + .pipeTo(Writable.toWeb(connection) as WritableStream, { + signal: abortController.signal, + }) .catch((err) => { + if (abortController.signal.aborted) { + return; + } console.error("Error piping data to socket:", err); - this.socket.destroy(err as Error); + const error = err instanceof Error ? err : new Error(String(err)); + this.socket?.destroy(error); }); } - /** - * The WritableStream to send data to the Meshtastic device. - */ + /** WritableStream to send data to the device. */ public get toDevice(): WritableStream { return this._toDevice; } - /** - * The ReadableStream to receive data from the Meshtastic device. - */ + /** ReadableStream to receive data from the device. */ public get fromDevice(): ReadableStream { return this._fromDevice; } - disconnect() { - this.socket.destroy(); - this.socket = undefined; - return Promise.resolve(); + /** + * Disconnect from the TCP socket and emit `DeviceDisconnected("user")`. + * Safe to call multiple times. + */ + async disconnect(): Promise { + try { + this.closingByUser = true; + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + + this.abortController.abort(); + if (this.pipePromise) { + await this.pipePromise; + } + + this.socket?.destroy(); + } finally { + this.socket = undefined; + this.closingByUser = false; + } + } + + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); } } diff --git a/packages/transport-web-bluetooth/src/transport.test.ts b/packages/transport-web-bluetooth/src/transport.test.ts new file mode 100644 index 00000000..9d9d0e63 --- /dev/null +++ b/packages/transport-web-bluetooth/src/transport.test.ts @@ -0,0 +1,166 @@ +import { describe, vi, expect, beforeEach, afterEach } from "vitest"; +import { runTransportContract } from "../../../tests/utils/transportContract"; +import { TransportWebBluetooth } from "./transport"; + +class MiniEmitter { + private listeners = new Map void>>(); + addEventListener(type: string, listener: (e: Event) => void) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(listener); + } + removeEventListener(type: string, listener: (e: Event) => void) { + this.listeners.get(type)?.delete(listener); + } + dispatchEvent(event: Event) { + this.listeners.get(event.type)?.forEach((l) => l(event)); + } +} + +function stubWebBluetooth() { + const incomingQueue: Uint8Array[] = []; + let lastWritten: Uint8Array | undefined; + + // fromRadioCharacteristic: read bytes from queue, one buffer per read + const fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { + async readValue() { + const next = incomingQueue.shift() ?? new Uint8Array(); + return new DataView( + next.buffer, + next.byteOffset, + next.byteLength, + ) as unknown as DataView; + }, + addEventListener() {}, + removeEventListener() {}, + } as unknown as BluetoothRemoteGATTCharacteristic; + + // characteristicvaluechanged event plumbing (fromNumCharacteristic) + const charEmitter = new MiniEmitter(); + + const fromNumCharacteristic: BluetoothRemoteGATTCharacteristic = { + async startNotifications() { + return this; + }, + addEventListener(type: string, listener: (e: Event) => void) { + charEmitter.addEventListener(type, listener); + }, + removeEventListener(type: string, listener: (e: Event) => void) { + charEmitter.removeEventListener(type, listener); + }, + } as unknown as BluetoothRemoteGATTCharacteristic; + + const toRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { + async writeValue(bufferSource: BufferSource) { + const u8 = + bufferSource instanceof ArrayBuffer + ? new Uint8Array(bufferSource) + : new Uint8Array( + bufferSource.buffer, + bufferSource.byteOffset, + bufferSource.byteLength, + ); + lastWritten = new Uint8Array(u8); + }, + } as unknown as BluetoothRemoteGATTCharacteristic; + + // Primary service returns our three characteristics by UUID + const primaryService: BluetoothRemoteGATTService = { + async getCharacteristic(uuid: string) { + if (uuid === TransportWebBluetooth.ToRadioUuid) return toRadioCharacteristic; + if (uuid === TransportWebBluetooth.FromRadioUuid) return fromRadioCharacteristic; + if (uuid === TransportWebBluetooth.FromNumUuid) return fromNumCharacteristic; + throw new Error("Unknown characteristic: " + uuid); + }, + } as unknown as BluetoothRemoteGATTService; + + // Device-level emitter to deliver gattserverdisconnected + const deviceEmitter = new MiniEmitter(); + + // GATT server with readonly connected + let isConnected = true; + const gattServer: BluetoothRemoteGATTServer = { + get connected() { + return isConnected; + }, + async connect() { + isConnected = true; + return gattServer; + }, + disconnect() { + isConnected = false; + deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); + }, + async getPrimaryService() { + return primaryService; + }, + device: { + addEventListener: (...args: Parameters) => + deviceEmitter.addEventListener(args[0] as string, args[1] as (e: Event) => void), + removeEventListener: (...args: Parameters) => + deviceEmitter.removeEventListener(args[0] as string, args[1] as (e: Event) => void), + } as unknown as BluetoothDevice, + } as unknown as BluetoothRemoteGATTServer; + + const fakeDevice: BluetoothDevice = { + async watchAdvertisements() {}, + gatt: gattServer, + } as unknown as BluetoothDevice; + + const fakeNavigator = { + bluetooth: { + async requestDevice() { + return fakeDevice; + }, + }, + }; + + vi.stubGlobal("navigator", Object.assign({}, globalThis.navigator, fakeNavigator)); + + // helper actions for tests/contract + return { + pushIncoming: (u8: Uint8Array) => { + incomingQueue.push(u8); + charEmitter.dispatchEvent(new Event("characteristicvaluechanged")); + }, + assertLastWritten: (u8: Uint8Array) => { + expect(lastWritten).toBeDefined(); + expect(lastWritten).toEqual(u8); + }, + // simulate underlying link drop (OS-level disconnect) + triggerGattDisconnect: () => { + isConnected = false; + deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); + }, + cleanup: () => { + vi.unstubAllGlobals(); + }, + }; +} + +describe("TransportWebBluetooth (contract)", () => { + runTransportContract({ + name: "TransportWebBluetooth", + setup: () => {}, + teardown: () => { + (globalThis as unknown as { __ble?: ReturnType }).__ble?.cleanup(); + (globalThis as unknown as { __ble?: ReturnType }).__ble = undefined; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }, + create: async () => { + (globalThis as unknown as { __ble: ReturnType }).__ble = stubWebBluetooth(); + return await TransportWebBluetooth.create(); + }, + pushIncoming: async (bytes) => { + (globalThis as unknown as { __ble: ReturnType }).__ble.pushIncoming(bytes); + await Promise.resolve(); + }, + assertLastWritten: (bytes) => { + (globalThis as unknown as { __ble: ReturnType }).__ble.assertLastWritten(bytes); + }, + triggerDisconnect: async () => { + (globalThis as unknown as { __ble: ReturnType }).__ble.triggerGattDisconnect(); + await Promise.resolve(); + }, + }); +}); diff --git a/packages/transport-web-bluetooth/src/transport.ts b/packages/transport-web-bluetooth/src/transport.ts index 05cf094c..00c0e0c4 100644 --- a/packages/transport-web-bluetooth/src/transport.ts +++ b/packages/transport-web-bluetooth/src/transport.ts @@ -1,21 +1,63 @@ -import type { Types } from "@meshtastic/core"; +import { Types } from "@meshtastic/core"; +function toArrayBuffer(uint8array: Uint8Array): ArrayBuffer { + if ( + uint8array.buffer instanceof ArrayBuffer && + uint8array.byteOffset === 0 && + uint8array.byteLength === uint8array.buffer.byteLength + ) { + return uint8array.buffer; + } + return uint8array.slice().buffer; +} + +/** + * Provides Web Bluetooth transport for Meshtastic devices. + * + * Implements the {@link Types.Transport} contract using the Web Bluetooth API. + * Use {@link TransportWebBluetooth.create} or {@link TransportWebBluetooth.createFromDevice} + * to construct an instance. + */ export class TransportWebBluetooth implements Types.Transport { private _toDevice: WritableStream; private _fromDevice: ReadableStream; - private _fromDeviceController?: ReadableStreamDefaultController; - private _isFirstWrite = true; + private fromDeviceController?: ReadableStreamDefaultController; private toRadioCharacteristic: BluetoothRemoteGATTCharacteristic; private fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic; private fromNumCharacteristic: BluetoothRemoteGATTCharacteristic; private gattServer: BluetoothRemoteGATTServer; + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + + private closingByUser = false; + private reading = false; + /** UUID for the "toRadio" write characteristic. */ static ToRadioUuid = "f75c76d2-129e-4dad-a1dd-7866124401e7"; + /** UUID for the "fromRadio" read characteristic. */ static FromRadioUuid = "2c55e69e-4993-11ed-b878-0242ac120002"; + /** UUID for the "fromNum" notification characteristic. */ static FromNumUuid = "ed9da18c-a800-4f66-a670-aa7547e34453"; + /** UUID for the Meshtastic GATT service. */ static ServiceUuid = "6ba1b218-15a8-461f-9fa8-5dcae273eafd"; + private onGattDisconnected = () => { + if (this.closingByUser) { + return; + } + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "gatt-disconnected", + ); + }; + private onFromNumChanged = () => { + void this.readFromRadio(); + }; + + /** + * Prompts the user to select a Bluetooth device, connects it, and returns a transport. + */ public static async create(): Promise { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], @@ -23,17 +65,25 @@ export class TransportWebBluetooth implements Types.Transport { return await TransportWebBluetooth.prepareConnection(device); } + /** + * Creates a transport from an existing, user-provided {@link BluetoothDevice}. + */ public static async createFromDevice( device: BluetoothDevice, ): Promise { return await TransportWebBluetooth.prepareConnection(device); } + /** + * Prepares and connects to a {@link BluetoothDevice}, resolving its GATT server + * and characteristics, then returning a transport. + * + * @throws if required services or characteristics are missing. + */ public static async prepareConnection( device: BluetoothDevice, ): Promise { const gattServer = await device.gatt?.connect(); - if (!gattServer) { throw new Error("Failed to connect to GATT server"); } @@ -41,7 +91,6 @@ export class TransportWebBluetooth implements Types.Transport { const service = await gattServer.getPrimaryService( TransportWebBluetooth.ServiceUuid, ); - const toRadioCharacteristic = await service.getCharacteristic( TransportWebBluetooth.ToRadioUuid, ); @@ -60,8 +109,6 @@ export class TransportWebBluetooth implements Types.Transport { throw new Error("Failed to find required characteristics"); } - console.log("Connected to device", device.name); - return new TransportWebBluetooth( toRadioCharacteristic, fromRadioCharacteristic, @@ -70,6 +117,10 @@ export class TransportWebBluetooth implements Types.Transport { ); } + /** + * Create a transport from resolved GATT characteristics and server. + * Prefer using the static factory methods instead. + */ constructor( toRadioCharacteristic: BluetoothRemoteGATTCharacteristic, fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic, @@ -81,66 +132,135 @@ export class TransportWebBluetooth implements Types.Transport { this.fromNumCharacteristic = fromNumCharacteristic; this.gattServer = gattServer; - this._fromDevice = new ReadableStream({ - start: (ctrl) => { - this._fromDeviceController = ctrl; - }, - }); + this._fromDevice = new ReadableStream({ + start: async (ctrl) => { + this.fromDeviceController = ctrl; + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); - this._toDevice = new WritableStream({ - write: async (chunk) => { - await this.toRadioCharacteristic.writeValue(chunk); + this.gattServer.device.addEventListener( + "gattserverdisconnected", + this.onGattDisconnected, + ); - if (this._isFirstWrite && this._fromDeviceController) { - this._isFirstWrite = false; - - setTimeout(() => { - // biome-ignore lint/style/noNonNullAssertion: we know this will be set - this.readFromRadio(this._fromDeviceController!); - }, 50); + try { + await this.fromNumCharacteristic.startNotifications(); + this.fromNumCharacteristic.addEventListener( + "characteristicvaluechanged", + this.onFromNumChanged, + ); + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); + // prime once in case data already queued + void this.readFromRadio(); + } catch { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "notify-failed", + ); + this.gattServer.device.removeEventListener( + "gattserverdisconnected", + this.onGattDisconnected, + ); } }, }); - this.fromNumCharacteristic.addEventListener( - "characteristicvaluechanged", - () => { - if (this._fromDeviceController) { - this.readFromRadio(this._fromDeviceController); + this._toDevice = new WritableStream({ + write: async (chunk) => { + try { + const ab = toArrayBuffer(chunk); + await this.toRadioCharacteristic.writeValue(ab); + void this.readFromRadio(); // ensure we read any response + } catch (error) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "write-error", + ); + throw error; } }, - ); - - this.fromNumCharacteristic.startNotifications(); + }); } + /** Writable stream of bytes to the device. */ get toDevice(): WritableStream { return this._toDevice; } + /** Readable stream of {@link Types.DeviceOutput} from the device. */ get fromDevice(): ReadableStream { return this._fromDevice; } - protected async readFromRadio( - controller: ReadableStreamDefaultController, - ): Promise { - let hasMoreData = true; - while (hasMoreData && this.fromRadioCharacteristic) { - const value = await this.fromRadioCharacteristic.readValue(); - if (value.byteLength === 0) { - hasMoreData = false; - continue; + /** + * Closes the GATT connection and emits `DeviceDisconnected("user")`. + */ + disconnect(): Promise { + try { + this.closingByUser = true; + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + try { + this.fromNumCharacteristic.stopNotifications?.(); + } catch {} + this.fromNumCharacteristic.removeEventListener( + "characteristicvaluechanged", + this.onFromNumChanged, + ); + this.gattServer.device.removeEventListener( + "gattserverdisconnected", + this.onGattDisconnected, + ); + + this.gattServer.disconnect(); + } finally { + this.closingByUser = false; + } + return Promise.resolve(); + } + + private async readFromRadio(): Promise { + if (this.reading) { + return; + } + this.reading = true; + + try { + let hasMoreData = true; + while (hasMoreData && this.fromRadioCharacteristic) { + const value = await this.fromRadioCharacteristic.readValue(); + if (value.byteLength === 0) { + hasMoreData = false; + continue; + } + this.enqueue({ + type: "packet", + data: new Uint8Array(value.buffer), + }); + } + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "read-error", + ); } - controller.enqueue({ - type: "packet", - data: new Uint8Array(value.buffer), - }); + throw error; + } finally { + this.reading = false; } } - disconnect(): Promise { - this.gattServer.disconnect(); - return Promise.resolve(); + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); + } + + private enqueue(output: Types.DeviceOutput): void { + this.fromDeviceController?.enqueue(output); } } diff --git a/packages/transport-web-serial/src/transport.test.ts b/packages/transport-web-serial/src/transport.test.ts new file mode 100644 index 00000000..0a724a15 --- /dev/null +++ b/packages/transport-web-serial/src/transport.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { TransportWebSerial } from "./transport"; +import { Types, Utils } from "@meshtastic/core"; +import { runTransportContract } from "../../../tests/utils/transportContract"; + +function stubCoreTransforms() { + const toDevice = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + + // maps raw bytes -> DeviceOutput.packet + const fromDeviceFactory = () => + new TransformStream({ + transform(chunk, controller) { + controller.enqueue({ type: "packet", data: chunk }); + }, + }); + + const restoreTo = vi + .spyOn(Utils, "toDeviceStream", "get") + .mockReturnValue(toDevice as unknown as typeof Utils.toDeviceStream); + + const restoreFrom = vi + .spyOn(Utils, "fromDeviceStream") + .mockImplementation( + () => + fromDeviceFactory() as unknown as TransformStream< + Uint8Array, + Types.DeviceOutput + >, + ); + + return { + restore: () => { + restoreTo.mockRestore(); + restoreFrom.mockRestore(); + }, + }; +} + +function stubNavigatorSerial() { + type SerialDisconnectHandler = (ev: { port?: any }) => void; + const handlers = new Set(); + + const serialStub = { + addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { + if (type === "disconnect") handlers.add(handler as any as SerialDisconnectHandler); + }, + removeEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { + if (type === "disconnect") handlers.delete(handler as any as SerialDisconnectHandler); + }, + dispatchDisconnect(port: any) { + for (const h of handlers) h({ port }); + }, + requestPort: vi.fn(async () => new FakeSerialPort()), + }; + + const nav: any = (globalThis as any).navigator ?? {}; + const hadNavigator = !!(globalThis as any).navigator; + const originalSerial = nav.serial; + + if (!hadNavigator) { + Object.defineProperty(globalThis as any, "navigator", { + value: nav, + configurable: true, + writable: false, + }); + } + + Object.defineProperty(nav, "serial", { + value: serialStub, + configurable: true, + enumerable: true, + writable: true, + }); + + return { + serialStub, + restore: () => { + if (hadNavigator) { + if (originalSerial === undefined) { + delete (globalThis as any).navigator.serial; + } else { + Object.defineProperty((globalThis as any).navigator, "serial", { + value: originalSerial, + configurable: true, + enumerable: true, + writable: true, + }); + } + } else { + delete (globalThis as any).navigator; + } + }, + }; +} + +class FakeSerialPort { + readable: ReadableStream; + writable: WritableStream; + lastWritten?: Uint8Array; + + private _readController!: ReadableStreamDefaultController; + + constructor() { + this.readable = new ReadableStream({ + start: (controller) => { + this._readController = controller; + }, + }); + + this.writable = new WritableStream({ + write: async (chunk) => { + this.lastWritten = chunk; + }, + }); + } + + open(_options?: { baudRate?: number }): Promise { + return Promise.resolve(); + } + + close(): Promise { + try { + this._readController.close(); + } catch {} + return Promise.resolve(); + } + + pushIncoming(bytes: Uint8Array) { + this._readController.enqueue(bytes); + } +} + +describe("TransportWebSerial (contract)", () => { + let transforms: { restore(): void } | undefined; + let navSerial: { serialStub: any; restore(): void } | undefined; + + beforeEach(() => { + transforms = stubCoreTransforms(); + navSerial = stubNavigatorSerial(); + }); + + afterEach(() => { + transforms?.restore(); + navSerial?.restore(); + vi.restoreAllMocks(); + }); + + runTransportContract({ + name: "TransportWebSerial", + setup: () => {}, + teardown: () => {}, + create: async () => { + const fake = new FakeSerialPort(); + const transport = await TransportWebSerial.createFromPort(fake as any); + (globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; + await Promise.resolve(); + return transport; + }, + pushIncoming: async (bytes) => { + (globalThis as any).__ws.fake.pushIncoming(bytes); + await Promise.resolve(); + }, + assertLastWritten: (bytes) => { + expect((globalThis as any).__ws.fake.lastWritten).toEqual(bytes); + }, + triggerDisconnect: async () => { + (globalThis as any).__ws.serial.dispatchDisconnect( + (globalThis as any).__ws.fake, + ); + await Promise.resolve(); + }, + }); +}); + +describe("TransportWebSerial (extras)", () => { + let transforms: { restore(): void } | undefined; + let navSerial: { serialStub: any; restore(): void } | undefined; + + beforeEach(() => { + transforms = stubCoreTransforms(); + navSerial = stubNavigatorSerial(); + }); + + afterEach(() => { + transforms?.restore(); + navSerial?.restore(); + vi.restoreAllMocks(); + }); + + it("emits DeviceDisconnected('serial-disconnected') on OS disconnect event", async () => { + const fake = new FakeSerialPort(); + const transport = await TransportWebSerial.createFromPort(fake as any); + (globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; + + const reader = transport.fromDevice.getReader(); + + // drain statuses until connected + for (let i = 0; i < 3; i++) { + const { value } = await reader.read(); + if (!value || value.type !== "status") break; + if (value.data.status === Types.DeviceStatusEnum.DeviceConnected) break; + } + + // fire OS-level disconnect + navSerial!.serialStub.dispatchDisconnect(fake as any); + await Promise.resolve(); + + let saw = false; + for (let i = 0; i < 6; i++) { + const { value } = await reader.read(); + if (value?.type === "status" && value.data.reason === "serial-disconnected") { + saw = true; + break; + } + } + expect(saw).toBe(true); + + reader.releaseLock(); + await transport.disconnect(); + }); +}); diff --git a/packages/transport-web-serial/src/transport.ts b/packages/transport-web-serial/src/transport.ts index 69aa35a1..949e4d5c 100644 --- a/packages/transport-web-serial/src/transport.ts +++ b/packages/transport-web-serial/src/transport.ts @@ -1,72 +1,165 @@ -import type { Types } from "@meshtastic/core"; -import { Utils } from "@meshtastic/core"; +import { Types, Utils } from "@meshtastic/core"; +/** + * Provides Web Serial transport for Meshtastic devices. + * + * Implements the {@link Types.Transport} contract using the Web Serial API. + * Use {@link TransportWebSerial.create} or {@link TransportWebSerial.createFromPort} + * to construct an instance. + */ export class TransportWebSerial implements Types.Transport { private _toDevice: WritableStream; private _fromDevice: ReadableStream; + private fromDeviceController?: ReadableStreamDefaultController; private connection: SerialPort; private pipePromise: Promise | null = null; private abortController: AbortController; + private portReadable: ReadableStream; + private lastStatus: Types.DeviceStatusEnum = + Types.DeviceStatusEnum.DeviceDisconnected; + private closingByUser = false; + + /** + * Create a new TransportWebSerial instance using a serial port. + */ public static async create(baudRate?: number): Promise { const port = await navigator.serial.requestPort(); await port.open({ baudRate: baudRate || 115200 }); return new TransportWebSerial(port); } + /** + * Creates a new TransportWebSerial instance from an existing, provided {@link SerialPort}. + * Opens it if not already open. + */ public static async createFromPort( port: SerialPort, baudRate?: number, ): Promise { - await port.open({ baudRate: baudRate || 115200 }); + if (!port.readable || !port.writable) { + await port.open({ baudRate: baudRate || 115200 }); + } return new TransportWebSerial(port); } + /** + * Constructs a transport around a given {@link SerialPort}. + * @throws If the port lacks readable or writable streams. + */ constructor(connection: SerialPort) { if (!connection.readable || !connection.writable) { throw new Error("Stream not accessible"); } this.connection = connection; + this.portReadable = connection.readable; this.abortController = new AbortController(); + const abortController = this.abortController; // Set up the pipe with abort signal for clean cancellation - this.pipePromise = Utils.toDeviceStream.readable.pipeTo( - connection.writable, - { signal: this.abortController.signal }, - ); + this.pipePromise = Utils.toDeviceStream.readable + .pipeTo(connection.writable, { signal: this.abortController.signal }) + .catch((err) => { + // Ignore expected rejection when we cancel it via the AbortController. + if (abortController.signal.aborted) { + return; + } + console.error("Error piping data to serial port:", err); + this.connection.close().catch(() => {}); + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "write-error", + ); + }); this._toDevice = Utils.toDeviceStream.writable; - this._fromDevice = connection.readable.pipeThrough( - Utils.fromDeviceStream(), - ); + + // Wrap + capture controller to inject status packets + this._fromDevice = new ReadableStream({ + start: async (ctrl) => { + this.fromDeviceController = ctrl; + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting); + + const transformed = this.portReadable.pipeThrough( + Utils.fromDeviceStream(), + ); + const reader = transformed.getReader(); + + const onOsDisconnect = (ev: Event) => { + const { port } = ev as unknown as { port?: SerialPort }; + if (port && port === this.connection) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "serial-disconnected", + ); + } + }; + navigator.serial.addEventListener("disconnect", onOsDisconnect); + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + ctrl.enqueue(value); + } + ctrl.close(); + } catch (error) { + if (!this.closingByUser) { + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "read-error", + ); + } + ctrl.error(error instanceof Error ? error : new Error(String(error))); + try { + await transformed.cancel(); + } catch {} + } finally { + reader.releaseLock(); + navigator.serial.removeEventListener("disconnect", onOsDisconnect); + } + }, + }); } - get toDevice(): WritableStream { + /** Writable stream of bytes to the device. */ + public get toDevice(): WritableStream { return this._toDevice; } - get fromDevice(): ReadableStream { + /** Readable stream of {@link Types.DeviceOutput} from the device. */ + public get fromDevice(): ReadableStream { return this._fromDevice; } + private emitStatus(next: Types.DeviceStatusEnum, reason?: string): void { + if (next === this.lastStatus) { + return; + } + this.lastStatus = next; + this.fromDeviceController?.enqueue({ + type: "status", + data: { status: next, reason }, + }); + } + /** - * Safely disconnects the serial port, following best practices from - * https://github.com/WICG/serial/. Cancels any active pipe - * operations and only closes the port after streams are unlocked. + * Closes the serial port and emits `DeviceDisconnected("user")`. */ - async disconnect() { + public async disconnect(): Promise { try { - this.abortController.abort(); + this.closingByUser = true; + // Stop outbound piping + this.abortController.abort(); if (this.pipePromise) { - try { - await this.pipePromise; - } catch (error) { - if (error instanceof Error && error.name !== "AbortError") { - throw error; - } - } + await this.pipePromise; } // Cancel any remaining streams @@ -82,6 +175,9 @@ export class TransportWebSerial implements Types.Transport { } catch (error) { // If we can't close cleanly, let the browser handle cleanup console.warn("Could not cleanly disconnect serial port:", error); + } finally { + this.emitStatus(Types.DeviceStatusEnum.DeviceDisconnected, "user"); + this.closingByUser = false; } } @@ -89,14 +185,43 @@ export class TransportWebSerial implements Types.Transport { * Reconnects the transport by creating a new AbortController and re-establishing * the pipe connection. Only call this after disconnect() or if the connection failed. */ - async reconnect() { - // Create a new AbortController for the new connection - this.abortController = new AbortController(); + public async reconnect() { + this.emitStatus(Types.DeviceStatusEnum.DeviceConnecting, "reconnect"); + + try { + if (!this.connection.readable || !this.connection.writable) { + throw new Error("Stream not accessible"); + } + this.portReadable = this.connection.readable; - // Re-establish the pipe connection - this.pipePromise = Utils.toDeviceStream.readable.pipeTo( - this.connection.writable, - { signal: this.abortController.signal }, - ); + // Create a new AbortController for the new connection + this.abortController = new AbortController(); + const abortController = this.abortController; + + // Re-establish the pipe connection + this.pipePromise = Utils.toDeviceStream.readable + .pipeTo(this.connection.writable, { + signal: this.abortController.signal, + }) + .catch((error) => { + if (abortController.signal.aborted) { + return; + } + console.error("Error piping data to serial port (reconnect):", error); + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "write-error", + ); + }); + + this.emitStatus(Types.DeviceStatusEnum.DeviceConnected, "reconnected"); + } catch (error) { + // Couldn’t re-pipe + this.emitStatus( + Types.DeviceStatusEnum.DeviceDisconnected, + "reconnect-failed", + ); + throw error; + } } } diff --git a/packages/web/public/i18n/locales/bg-BG/commandPalette.json b/packages/web/public/i18n/locales/bg-BG/commandPalette.json index 5ce1fc7c..dd28a5fc 100644 --- a/packages/web/public/i18n/locales/bg-BG/commandPalette.json +++ b/packages/web/public/i18n/locales/bg-BG/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Генератор", "qrImport": "Импортиране", "scheduleShutdown": "Планирано изключване", - "scheduleReboot": "Планирано рестартиране", - "rebootToOtaMode": "Рестартиране в режим OTA", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Нулиране на базата данни с възли", + "dfuMode": "Влизане в DFU режим", "factoryResetDevice": "Фабрично нулиране на устройството", "factoryResetConfig": "Фабрично нулиране на конфигурацията", "disconnect": "Прекъсване" diff --git a/packages/web/public/i18n/locales/bg-BG/common.json b/packages/web/public/i18n/locales/bg-BG/common.json index 3c2d7209..86424df7 100644 --- a/packages/web/public/i18n/locales/bg-BG/common.json +++ b/packages/web/public/i18n/locales/bg-BG/common.json @@ -17,7 +17,6 @@ "now": "Сега", "ok": "Добре", "print": "Отпечатване", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Изтрий", "requestNewKeys": "Заявка за нови ключове", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "Изисква се поне един администраторски ключ, ако възелът е управляван.", "key": "Ключът е задължителен." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/bg-BG/dialog.json b/packages/web/public/i18n/locales/bg-BG/dialog.json index 1c94594f..216a6a79 100644 --- a/packages/web/public/i18n/locales/bg-BG/dialog.json +++ b/packages/web/public/i18n/locales/bg-BG/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Неизвестна грешка при зареждане на устройствата" }, "validation": { - "requiresFeatures": "Този тип връзка изисква <0>. Моля, използвайте поддържан браузър, като Chrome или Edge.", + "requiresWebBluetooth": "Този тип връзка изисква <0>Web Bluetooth. Моля, използвайте поддържан браузър, като Chrome или Edge.", + "requiresWebSerial": "Този тип връзка изисква <0>Web Serial. Моля, използвайте поддържан браузър, като Chrome или Edge.", "requiresSecureContext": "Това приложение изисква <0>secure context. Моля, свържете се чрез HTTPS или localhost.", "additionallyRequiresSecureContext": "Освен това, изисква <0>secure context. Моля, свържете се чрез HTTPS или localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Хардуер: ", "lastHeard": "Последно чут: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Номер на възела: ", "position": "Позиция:", "role": "Роля: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Игнориране на възела", "removeNode": "Премахване на възела", - "unignoreNode": "Премахване на игнорирането на възела" + "unignoreNode": "Премахване на игнорирането на възела", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "Ако загубите ключовете си, ще трябва да нулирате устройството си.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Генериране на QR код" }, - "rebootOta": { - "title": "Планирано рестартиране", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Въведете забавяне (сек)", - "scheduled": "Насрочено е рестартиране" - }, "reboot": { - "title": "Планирано рестартиране", - "description": "Рестартиране на свързания възел след x минути." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Насрочено е рестартиране", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Да, знам какво правя", "title": "Сигурни ли сте?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/cs-CZ/commandPalette.json b/packages/web/public/i18n/locales/cs-CZ/commandPalette.json index 1fe13952..8b8c0101 100644 --- a/packages/web/public/i18n/locales/cs-CZ/commandPalette.json +++ b/packages/web/public/i18n/locales/cs-CZ/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Import", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Enter DFU Mode", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Disconnect" diff --git a/packages/web/public/i18n/locales/cs-CZ/common.json b/packages/web/public/i18n/locales/cs-CZ/common.json index e1ace668..6e1f2d1e 100644 --- a/packages/web/public/i18n/locales/cs-CZ/common.json +++ b/packages/web/public/i18n/locales/cs-CZ/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "OK", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Odstranit", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Ano", + "no": "Ne" } diff --git a/packages/web/public/i18n/locales/cs-CZ/dialog.json b/packages/web/public/i18n/locales/cs-CZ/dialog.json index 37115d67..6711f3b7 100644 --- a/packages/web/public/i18n/locales/cs-CZ/dialog.json +++ b/packages/web/public/i18n/locales/cs-CZ/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Jste si jistý?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Oznámení klienta", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/de-DE/commandPalette.json b/packages/web/public/i18n/locales/de-DE/commandPalette.json index 67fbadc9..637a01b3 100644 --- a/packages/web/public/i18n/locales/de-DE/commandPalette.json +++ b/packages/web/public/i18n/locales/de-DE/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Importieren", "scheduleShutdown": "Herunterfahren planen", - "scheduleReboot": "Neustarten planen", - "rebootToOtaMode": "Neustart in den OTA Modus", + "scheduleReboot": "Gerät neustarten", "resetNodeDb": "Knotendatenbank zurücksetzen", + "dfuMode": "In den DFU-Modus wechseln", "factoryResetDevice": "Gerät auf Werkseinstellungen zurücksetzen", "factoryResetConfig": "Auf Werkseinstellungen zurücksetzen", "disconnect": "Verbindung trennen" diff --git a/packages/web/public/i18n/locales/de-DE/common.json b/packages/web/public/i18n/locales/de-DE/common.json index 04b11d34..272f5652 100644 --- a/packages/web/public/i18n/locales/de-DE/common.json +++ b/packages/web/public/i18n/locales/de-DE/common.json @@ -17,7 +17,6 @@ "now": "Jetzt", "ok": "Ok", "print": "Drucken", - "rebootOtaNow": "Neustart in den OTA Modus", "remove": "Entfernen", "requestNewKeys": "Neue Schlüssel anfordern", "requestPosition": "Standort anfordern", @@ -137,5 +136,7 @@ "managed": "Mindestens ein administrativer Schlüssel wird benötigt, um diesen Knoten zu verwalten", "key": "Schlüssel erforderlich." } - } + }, + "yes": "Ja", + "no": "Nein" } diff --git a/packages/web/public/i18n/locales/de-DE/dialog.json b/packages/web/public/i18n/locales/de-DE/dialog.json index e1b9a87f..aef1c254 100644 --- a/packages/web/public/i18n/locales/de-DE/dialog.json +++ b/packages/web/public/i18n/locales/de-DE/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unbekannter Fehler beim Laden der Geräte" }, "validation": { - "requiresFeatures": "Dieser Verbindungstyp erfordert <0>. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.", + "requiresWebBluetooth": "Dieser Verbindungstyp erfordert <0>Bluetooth im Browser. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.", + "requiresWebSerial": "Dieser Verbindungstyp erfordert <0>Serielle Schnittstelle im Browser. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.", "requiresSecureContext": "Diese Anwendung erfordert einen <0>sicheren Kontext. Bitte verbinden Sie sich über HTTPS oder localhost.", "additionallyRequiresSecureContext": "Zusätzlich erfordert es einen <0>sicheren Kontext. Bitte verbinden Sie sich über HTTPS oder localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Gerätekennzahlen:", "hardware": "Hardware: ", "lastHeard": "Zuletzt gehört: ", - "nodeHexPrefix": "Knoten ID: !", + "nodeHexPrefix": "Knoten ID:", "nodeNumber": "Knotennummer: ", "position": "Standort:", "role": "Rolle: ", @@ -101,7 +102,12 @@ "title": "Knotendetails für {{identifier}}", "ignoreNode": "Knoten ignorieren", "removeNode": "Knoten entfernen", - "unignoreNode": "Knoten akzeptieren" + "unignoreNode": "Knoten akzeptieren", + "security": "Sicherheit:", + "publicKey": "Öffentlicher Schlüssel:", + "messageable": "Ansprechbar:", + "KeyManuallyVerifiedTrue": "Öffentlicher Schlüssel wurde manuell geprüft", + "KeyManuallyVerifiedFalse": "Öffentlicher Schlüssel ist nicht manuell geprüft" }, "pkiBackup": { "loseKeysWarning": "Wenn Sie Ihre Schlüssel verlieren, müssen Sie Ihr Gerät zurücksetzen.", @@ -131,15 +137,15 @@ "sharableUrl": "Teilbare URL", "title": "QR Code Erzeugen" }, - "rebootOta": { - "title": "Neustart planen", - "description": "Startet den verbundenen Knoten nach einer Verzögerung in den OTA (Over-the-Air) Modus.", - "enterDelay": "Verzögerung eingeben (Sek.)", - "scheduled": "Neustart wurde geplant" - }, "reboot": { - "title": "Neustart planen", - "description": "Startet den verbundenen Knoten nach x Minuten neu." + "title": "Gerät neustarten", + "description": "Starten Sie jetzt neu oder planen Sie einen Neustart des verbundenen Knotens. Optional können Sie einen Neustart in den OTA (Over-the-Air) Modus wählen.", + "ota": "Neustart in den OTA Modus", + "enterDelay": "Verzögerung eingeben", + "scheduled": "Neustart wurde geplant", + "schedule": "Neustart planen", + "now": "Jetzt neustarten", + "cancel": "Geplanten Neustart abbrechen" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Ja, ich weiß, was ich tue!", "title": "Sind Sie sicher?", "description": "Das Aktivieren des verwalteten Modus blockiert das Schreiben der Einstellungen in das Funkgerät durch alle Anwendungen (einschließlich der Webanwendung). Einmal aktiviert, können die Einstellungen nur durch administrative Nachrichten geändert werden. Diese Einstellung ist für die Fernverwaltung von abgesetzten Knoten nicht erforderlich." + }, + "clientNotification": { + "title": "Warnmeldung", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute kann nur einmal alle 30 Sekunden versendet werden.", + "Compromised keys were detected and regenerated.": "Kompromittierte Schlüssel wurden erkannt und neu generiert." } } diff --git a/packages/web/public/i18n/locales/es-ES/commandPalette.json b/packages/web/public/i18n/locales/es-ES/commandPalette.json index 48bf08a8..4d7181c8 100644 --- a/packages/web/public/i18n/locales/es-ES/commandPalette.json +++ b/packages/web/public/i18n/locales/es-ES/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Importar", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Entrar en modo DFU", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Desconectar" diff --git a/packages/web/public/i18n/locales/es-ES/common.json b/packages/web/public/i18n/locales/es-ES/common.json index a11c56e4..65164a52 100644 --- a/packages/web/public/i18n/locales/es-ES/common.json +++ b/packages/web/public/i18n/locales/es-ES/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "Vale", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Quitar", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/es-ES/dialog.json b/packages/web/public/i18n/locales/es-ES/dialog.json index 49e408b7..a3f5d01c 100644 --- a/packages/web/public/i18n/locales/es-ES/dialog.json +++ b/packages/web/public/i18n/locales/es-ES/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "¿Estás seguro?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/fi-FI/commandPalette.json b/packages/web/public/i18n/locales/fi-FI/commandPalette.json index 07037002..4b8e8e83 100644 --- a/packages/web/public/i18n/locales/fi-FI/commandPalette.json +++ b/packages/web/public/i18n/locales/fi-FI/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "QR-koodigeneraattori", "qrImport": "Tuo", "scheduleShutdown": "Ajasta sammutus", - "scheduleReboot": "Ajasta uudelleenkäynnistys", - "rebootToOtaMode": "Uudelleenkäynnistä OTA-tilaan", + "scheduleReboot": "Käynnistä laite uudelleen", "resetNodeDb": "Nollaa laitteen DB-tietokanta", + "dfuMode": "Syötä DFU-tila", "factoryResetDevice": "Palauta tehdasasetukset", "factoryResetConfig": "Tehdasasetusten palautusasetukset", "disconnect": "Katkaise yhteys" diff --git a/packages/web/public/i18n/locales/fi-FI/common.json b/packages/web/public/i18n/locales/fi-FI/common.json index 3a565dcf..f1f77c0e 100644 --- a/packages/web/public/i18n/locales/fi-FI/common.json +++ b/packages/web/public/i18n/locales/fi-FI/common.json @@ -17,7 +17,6 @@ "now": "Nyt", "ok": "OK", "print": "Tulosta", - "rebootOtaNow": "Käynnistä uudelleen OTA-tilaan nyt", "remove": "Poista", "requestNewKeys": "Pyydä uudet avaimet", "requestPosition": "Pyydä sijaintia", @@ -137,5 +136,7 @@ "managed": "Vähintään yksi hallinta-avain vaaditaan, jos laitetta hallitaan.", "key": "Avain on pakollinen." } - } + }, + "yes": "Kyllä", + "no": "Ei" } diff --git a/packages/web/public/i18n/locales/fi-FI/dialog.json b/packages/web/public/i18n/locales/fi-FI/dialog.json index 1bc86292..7f33bec2 100644 --- a/packages/web/public/i18n/locales/fi-FI/dialog.json +++ b/packages/web/public/i18n/locales/fi-FI/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Tuntematon virhe laitteita ladattaessa" }, "validation": { - "requiresFeatures": "Tämä yhteystyyppi vaatii <0>. Käytä tuettua selainta, kuten Chromea tai Edgeä.", + "requiresWebBluetooth": "Tämä yhteystyyppi vaatii <0>Web-sarjaportti bluetooth -tuen. Käytä tuettua selainta, kuten Chromea tai Edgeä.", + "requiresWebSerial": "Tämä yhteystyyppi vaatii <0>Web-sarjaportti -tuen. Käytä tuettua selainta, kuten Chromea tai Edgeä.", "requiresSecureContext": "Tämä sovellus vaatii <0>suojatun yhteyden. Yhdistä käyttämällä HTTPS:ää tai localhostia.", "additionallyRequiresSecureContext": "Lisäksi se vaatii <0>suojatun yhteyden. Yhdistä käyttämällä HTTPS:ää tai localhostia." } @@ -92,7 +93,7 @@ "deviceMetrics": "Laitteen mittausloki:", "hardware": "Laitteisto: ", "lastHeard": "Viimeksi kuultu: ", - "nodeHexPrefix": "Laitteen Hex: !", + "nodeHexPrefix": "Laitteen Hex: ", "nodeNumber": "Laitteen numero: ", "position": "Sijainti:", "role": "Rooli: ", @@ -101,7 +102,12 @@ "title": "Tiedot laitteelle {{identifier}}", "ignoreNode": "Älä huomioi laitetta", "removeNode": "Poista laite", - "unignoreNode": "Poista laitteen ohitus käytöstä" + "unignoreNode": "Poista laitteen ohitus käytöstä", + "security": "Turvallisuus:", + "publicKey": "Julkinen avain: ", + "messageable": "Viestittävissä: ", + "KeyManuallyVerifiedTrue": "Julkinen avain on vahvistettu manuaalisesti", + "KeyManuallyVerifiedFalse": "Julkista avainta ei ole vahvistettu manuaalisesti" }, "pkiBackup": { "loseKeysWarning": "Jos menetät avaimesi, sinun täytyy palauttaa laite tehdasasetuksiin.", @@ -131,15 +137,15 @@ "sharableUrl": "Jaettava URL", "title": "Generoi QR-koodi" }, - "rebootOta": { - "title": "Ajasta uudelleenkäynnistys", - "description": "Käynnistä yhdistetty laite uudelleen viiveen jälkeen OTA-tilaan (langaton päivitys).", - "enterDelay": "Anna viive (sekuntia)", - "scheduled": "Uudelleenkäynnistys on ajastettu" - }, "reboot": { - "title": "Ajasta uudelleenkäynnistys", - "description": "Käynnistä yhdistetty laite x minuutin jälkeen." + "title": "Käynnistä laite uudelleen", + "description": "Käynnistä laite uudelleen heti tai ajasta sen uudelleenkäynnistys. Halutessasi voit valita käynnistyksen OTA-tilaan (Over-the-Air ohjelmistopäivitystilaan).", + "ota": "Käynnistä uudelleen OTA-tilaan", + "enterDelay": "Aseta viive", + "scheduled": "Uudelleenkäynnistys on ajastettu", + "schedule": "Ajasta uudelleen käynnistys", + "now": "Uudelleen käynnistä nyt", + "cancel": "Peruuta ajastettu uudelleen käynnistys" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Kyllä, tiedän mitä teen", "title": "Oletko varma?", "description": "Hallintatilan käyttöönotto estää asiakassovelluksia (mukaan lukien verkkoselain) tekemästä muutoksia laitteen asetuksiin. Kun hallintatila on käytössä, laitteen asetuksia voidaan muuttaa ainoastaan etähallintaviesteillä (Remote Admin). Tämä asetus ei ole pakollinen etähallintaan." + }, + "clientNotification": { + "title": "Sovellusilmoitukset", + "TraceRoute can only be sent once every 30 seconds": "Reitinselvityksen voi tehdä kerran 30:ssä sekunnissa", + "Compromised keys were detected and regenerated.": "Turvallisuudeltaan vaarantuneet avaimet havaittiin ja ne generoitiin uudestaan." } } diff --git a/packages/web/public/i18n/locales/fr-FR/commandPalette.json b/packages/web/public/i18n/locales/fr-FR/commandPalette.json index 169f4213..ef6cbec9 100644 --- a/packages/web/public/i18n/locales/fr-FR/commandPalette.json +++ b/packages/web/public/i18n/locales/fr-FR/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Générateur", "qrImport": "Importer", "scheduleShutdown": "Planifier l'arrêt", - "scheduleReboot": "Effacer tous les messages stockés", - "rebootToOtaMode": "Redémarrer en mode OTA", + "scheduleReboot": "Redémarrer l'appareil", "resetNodeDb": "Réinitialiser la DB des nœuds", + "dfuMode": "Passer en mode DFU", "factoryResetDevice": "Réinitialisation d'usine de l'appareil", "factoryResetConfig": "Réinitialisation d'usine de la config", "disconnect": "Déconnecter" diff --git a/packages/web/public/i18n/locales/fr-FR/common.json b/packages/web/public/i18n/locales/fr-FR/common.json index 4739b11f..9eb51063 100644 --- a/packages/web/public/i18n/locales/fr-FR/common.json +++ b/packages/web/public/i18n/locales/fr-FR/common.json @@ -17,7 +17,6 @@ "now": "Maintenant", "ok": "D'accord", "print": "Imprimer", - "rebootOtaNow": "Redémarrer en mode OTA", "remove": "Supprimer", "requestNewKeys": "Demander de nouvelles clés", "requestPosition": "Demander la position", @@ -137,5 +136,7 @@ "managed": "Au moins une clé d’administration est requise si le nœud est géré.", "key": "Clé requise." } - } + }, + "yes": "Oui", + "no": "Non" } diff --git a/packages/web/public/i18n/locales/fr-FR/dialog.json b/packages/web/public/i18n/locales/fr-FR/dialog.json index fad694ec..3d795cf2 100644 --- a/packages/web/public/i18n/locales/fr-FR/dialog.json +++ b/packages/web/public/i18n/locales/fr-FR/dialog.json @@ -9,10 +9,10 @@ "shortName": "Nom court", "title": "Changer le nom de l’appareil", "validation": { - "longNameMax": "Long name must not be more than 40 characters", - "shortNameMax": "Short name must not be more than 4 characters", - "longNameMin": "Long name must have at least 1 character", - "shortNameMin": "Short name must have at least 1 character" + "longNameMax": "Le nom long ne doit pas contenir plus de 40 caractères", + "shortNameMax": "Le nom court ne doit pas contenir plus de 4 caractères", + "longNameMin": "Le nom long doit contenir au moins 1 caractère", + "shortNameMin": "Le nom court doit contenir au moins 1 caractère" } }, "import": { @@ -30,7 +30,7 @@ "title": "Position : {{identifier}}", "altitude": "Altitude: ", "coordinates": "Coordonnées : ", - "noCoordinates": "No Coordinates" + "noCoordinates": "Pas de coordonnées" }, "pkiRegenerateDialog": { "title": "Régénérer la clé pré-partagée ?", @@ -68,14 +68,15 @@ "bluetoothConnection": { "noDevicesPaired": "Aucun appareil appairé pour l’instant.", "newDeviceButton": "Nouvel appareil", - "connectionFailed": "Connection failed", - "deviceDisconnected": "Device disconnected", - "unknownDevice": "Unknown Device", - "errorLoadingDevices": "Error loading devices", - "unknownErrorLoadingDevices": "Unknown error loading devices" + "connectionFailed": "Échec de la connexion", + "deviceDisconnected": "Périphérique déconnecté", + "unknownDevice": "Périphérique inconnu", + "errorLoadingDevices": "Erreur lors du chargement des périphériques", + "unknownErrorLoadingDevices": "Erreur inconnue lors du chargement des périphériques" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "Ce type de connexion nécessite <0>Web Bluetooth. Veuillez utiliser un navigateur compatible, comme Chrome ou Edge.<0>", + "requiresWebSerial": "Ce type de connexion nécessite <0>Web Serial. Veuillez utiliser un navigateur compatible, comme Chrome ou Edge.", "requiresSecureContext": "Cette application nécessite un <0>contexte sécurisé. Veuillez vous connecter via HTTPS ou localhost.", "additionallyRequiresSecureContext": "Elle nécessite également un <0>contexte sécurisé. Veuillez vous connecter via HTTPS ou localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Métriques de l’appareil :", "hardware": "Matériel : ", "lastHeard": "Dernière écoute: ", - "nodeHexPrefix": "Hexa du nœud : !", + "nodeHexPrefix": "Hex du nœud: ", "nodeNumber": "Numéro de nœud: ", "position": "Position:", "role": "Rôle: ", @@ -101,7 +102,12 @@ "title": "Détails du nœud pour {{identifier}}", "ignoreNode": "Ignorer le nœud", "removeNode": "Supprimer le nœud", - "unignoreNode": "Ne plus ignorer le nœud" + "unignoreNode": "Ne plus ignorer le nœud", + "security": "Sécurité:", + "publicKey": "Clé publique: ", + "messageable": "Messageable ", + "KeyManuallyVerifiedTrue": "La clé publique a été vérifiée manuellement", + "KeyManuallyVerifiedFalse": "La clé publique n'est pas vérifiée manuellement" }, "pkiBackup": { "loseKeysWarning": "Si vous perdez vos clés, vous devrez réinitialiser votre appareil.", @@ -131,15 +137,15 @@ "sharableUrl": "URL partageable", "title": "Générer un QR Code" }, - "rebootOta": { - "title": "Programmer un redémarrage", - "description": "Redémarrer le nœud connecté après un délai en mode OTA (Over-the-Air).", - "enterDelay": "Entrer le délai (sec)", - "scheduled": "Le redémarrage a été programmé" - }, "reboot": { - "title": "Programmer un redémarrage", - "description": "Redémarrer le nœud connecté après x minutes." + "title": "Redémarrer l'appareil", + "description": "Redémarrez maintenant ou programmez un redémarrage du nœud connecté. Vous pouvez éventuellement choisir de redémarrer en mode OTA (Over-the-Air).", + "ota": "Redémarrer en mode OTA", + "enterDelay": "Entrez le délai", + "scheduled": "Le redémarrage a été programmé", + "schedule": "Programmer le redémarrage", + "now": "Redémarrer maintenant", + "cancel": "Annuler le redémarrage planifié" }, "refreshKeys": { "description": { @@ -177,6 +183,11 @@ "managedMode": { "confirmUnderstanding": "Oui, je sais ce que je fais", "title": "Êtes-vous sûr ?", - "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + "description": "Activer le mode géré empêche les applications clientes (y compris l’interface web) de modifier la configuration d’une radio. Une fois activé, la configuration ne peut être changée que via des messages d’administration à distance. Ce paramètre n’est pas nécessaire pour l’administration distante des nœuds." + }, + "clientNotification": { + "title": "Notification client", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute ne peut être envoyée qu'une fois toutes les 30 secondes", + "Compromised keys were detected and regenerated.": "Les clés compromises ont été détectées et régénérées." } } diff --git a/packages/web/public/i18n/locales/it-IT/commandPalette.json b/packages/web/public/i18n/locales/it-IT/commandPalette.json index 2e28f004..9c48bf15 100644 --- a/packages/web/public/i18n/locales/it-IT/commandPalette.json +++ b/packages/web/public/i18n/locales/it-IT/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generatore", "qrImport": "Importa", "scheduleShutdown": "Pianifica Spegnimento", - "scheduleReboot": "Pianifica Riavvio", - "rebootToOtaMode": "Riavvia In Modalità OTA", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Resetta DB dei Nodi", + "dfuMode": "Entra In Modalità DFU", "factoryResetDevice": "Factory reset dispositivo", "factoryResetConfig": "Factory reset impostazioni", "disconnect": "Disconnetti" diff --git a/packages/web/public/i18n/locales/it-IT/common.json b/packages/web/public/i18n/locales/it-IT/common.json index e2611036..aa0ae3b0 100644 --- a/packages/web/public/i18n/locales/it-IT/common.json +++ b/packages/web/public/i18n/locales/it-IT/common.json @@ -17,7 +17,6 @@ "now": "Adesso", "ok": "Ok", "print": "Stampa", - "rebootOtaNow": "Riavvia ora in modalità OTA", "remove": "Elimina", "requestNewKeys": "Richiedi Nuove Chiavi", "requestPosition": "Richiedi posizione", @@ -137,5 +136,7 @@ "managed": "Se il nodo è gestito, è richiesta almeno una chiave amministrativa.", "key": "La chiave è obbligatoria." } - } + }, + "yes": "Sì", + "no": "No" } diff --git a/packages/web/public/i18n/locales/it-IT/dialog.json b/packages/web/public/i18n/locales/it-IT/dialog.json index 91e9fdf8..f7bac97a 100644 --- a/packages/web/public/i18n/locales/it-IT/dialog.json +++ b/packages/web/public/i18n/locales/it-IT/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "Questo tipo di connessione richiede <0>. Si prega di utilizzare un browser supportato, come Chrome o Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "Questa applicazione richiede un <0>contesto sicuro. Si prega di connettersi utilizzando HTTPS o localhost.", "additionallyRequiresSecureContext": "Inoltre, richiede un <0>contesto sicuro. Si prega di connettersi utilizzando HTTPS o localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Metriche Dispositivo:", "hardware": "Hardware: ", "lastHeard": "Ultimo Contatto: ", - "nodeHexPrefix": "Nodo Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Numero Nodo: ", "position": "Posizione:", "role": "Ruolo: ", @@ -101,7 +102,12 @@ "title": "Dettagli nodo per {{identifier}}", "ignoreNode": "Ignora nodo", "removeNode": "Rimuovi Nodo", - "unignoreNode": "Non ignorare più nodo" + "unignoreNode": "Non ignorare più nodo", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "Se perdi le tue chiavi, dovrai reimpostare il tuo dispositivo.", @@ -131,15 +137,15 @@ "sharableUrl": "URL Condivisibile", "title": "Genera codice QR" }, - "rebootOta": { - "title": "Pianifica Riavvio", - "description": "Riavvia il nodo connesso dopo un ritardo in modalità OTA (Over-the-Air).", - "enterDelay": "Inserisci ritardo (sec)", - "scheduled": "Il riavvio è stato pianificato" - }, "reboot": { - "title": "Pianifica Riavvio", - "description": "Riavvia il nodo connesso dopo x minuti." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Il riavvio è stato pianificato", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Sì, so cosa sto facendo", "title": "Sei sicuro?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Notifiche Client", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/ja-JP/commandPalette.json b/packages/web/public/i18n/locales/ja-JP/commandPalette.json index b913a820..8f8b06fc 100644 --- a/packages/web/public/i18n/locales/ja-JP/commandPalette.json +++ b/packages/web/public/i18n/locales/ja-JP/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "インポート", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Enter DFU Mode", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Disconnect" diff --git a/packages/web/public/i18n/locales/ja-JP/common.json b/packages/web/public/i18n/locales/ja-JP/common.json index d8714638..2dfbfefe 100644 --- a/packages/web/public/i18n/locales/ja-JP/common.json +++ b/packages/web/public/i18n/locales/ja-JP/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "OK", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "削除", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/ja-JP/dialog.json b/packages/web/public/i18n/locales/ja-JP/dialog.json index 4ec87f41..da289ba3 100644 --- a/packages/web/public/i18n/locales/ja-JP/dialog.json +++ b/packages/web/public/i18n/locales/ja-JP/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "よろしいですか?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/ko-KR/commandPalette.json b/packages/web/public/i18n/locales/ko-KR/commandPalette.json index c87764bf..b23b47d9 100644 --- a/packages/web/public/i18n/locales/ko-KR/commandPalette.json +++ b/packages/web/public/i18n/locales/ko-KR/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "생성", "qrImport": "불러오기", "scheduleShutdown": "종료 예약", - "scheduleReboot": "재부팅 예약", - "rebootToOtaMode": "OTA 모드로 재부팅", + "scheduleReboot": "Reboot Device", "resetNodeDb": "노드 목록 초기화", + "dfuMode": "DFU 모드 진입", "factoryResetDevice": "공장 초기화", "factoryResetConfig": "설정 초기", "disconnect": "연결 끊기" diff --git a/packages/web/public/i18n/locales/ko-KR/common.json b/packages/web/public/i18n/locales/ko-KR/common.json index 9af55ab8..3febcacf 100644 --- a/packages/web/public/i18n/locales/ko-KR/common.json +++ b/packages/web/public/i18n/locales/ko-KR/common.json @@ -17,7 +17,6 @@ "now": "지금", "ok": "확인", "print": "인쇄", - "rebootOtaNow": "지금 OTA 모드로 재부팅", "remove": "지우기", "requestNewKeys": "새로운 키 요청", "requestPosition": "위치 요청", @@ -137,5 +136,7 @@ "managed": "노드 관리를 위해 최소 한 개의 관리자 키가 필요합니다.", "key": "키가 필요합니다." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/ko-KR/dialog.json b/packages/web/public/i18n/locales/ko-KR/dialog.json index d0e10dd0..c8336156 100644 --- a/packages/web/public/i18n/locales/ko-KR/dialog.json +++ b/packages/web/public/i18n/locales/ko-KR/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "재부팅 예약", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "재부팅 예약", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "확실합니까?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "클라이언트 알림", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/nl-NL/commandPalette.json b/packages/web/public/i18n/locales/nl-NL/commandPalette.json index 2f0c6866..da965c23 100644 --- a/packages/web/public/i18n/locales/nl-NL/commandPalette.json +++ b/packages/web/public/i18n/locales/nl-NL/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Importeer", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "DFU-modus activeren", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Verbinding verbreken" diff --git a/packages/web/public/i18n/locales/nl-NL/common.json b/packages/web/public/i18n/locales/nl-NL/common.json index 8f3f56d9..2aba0c32 100644 --- a/packages/web/public/i18n/locales/nl-NL/common.json +++ b/packages/web/public/i18n/locales/nl-NL/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "OK", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Verwijder", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/nl-NL/dialog.json b/packages/web/public/i18n/locales/nl-NL/dialog.json index 099886dd..fad2cd24 100644 --- a/packages/web/public/i18n/locales/nl-NL/dialog.json +++ b/packages/web/public/i18n/locales/nl-NL/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Weet u het zeker?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/pl-PL/commandPalette.json b/packages/web/public/i18n/locales/pl-PL/commandPalette.json index dd4e6ec7..018f88a6 100644 --- a/packages/web/public/i18n/locales/pl-PL/commandPalette.json +++ b/packages/web/public/i18n/locales/pl-PL/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Import", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Wejdź w tryb DFU", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Rozłącz" diff --git a/packages/web/public/i18n/locales/pl-PL/common.json b/packages/web/public/i18n/locales/pl-PL/common.json index 26550e78..fc1709d8 100644 --- a/packages/web/public/i18n/locales/pl-PL/common.json +++ b/packages/web/public/i18n/locales/pl-PL/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "OK", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Usuń", "requestNewKeys": "Request New Keys", "requestPosition": "Poproś o pozycję", @@ -137,5 +136,7 @@ "managed": "Co najmniej jeden klucz administratora jest wymagany, jeśli węzeł jest zarządzany.", "key": "Klucz jest wymagany." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/pl-PL/dialog.json b/packages/web/public/i18n/locales/pl-PL/dialog.json index f46dae09..de94967d 100644 --- a/packages/web/public/i18n/locales/pl-PL/dialog.json +++ b/packages/web/public/i18n/locales/pl-PL/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generuj Kod QR" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Jesteś pewny?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/pt-BR/commandPalette.json b/packages/web/public/i18n/locales/pt-BR/commandPalette.json index b8d676f5..6dabdcd6 100644 --- a/packages/web/public/i18n/locales/pt-BR/commandPalette.json +++ b/packages/web/public/i18n/locales/pt-BR/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Importar", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Enter DFU Mode", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Desconectar" diff --git a/packages/web/public/i18n/locales/pt-BR/common.json b/packages/web/public/i18n/locales/pt-BR/common.json index 2e7d287e..eb333dd3 100644 --- a/packages/web/public/i18n/locales/pt-BR/common.json +++ b/packages/web/public/i18n/locales/pt-BR/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "Ok", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Excluir", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/pt-BR/dialog.json b/packages/web/public/i18n/locales/pt-BR/dialog.json index 98708da5..8a9c0280 100644 --- a/packages/web/public/i18n/locales/pt-BR/dialog.json +++ b/packages/web/public/i18n/locales/pt-BR/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Você tem certeza?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Notificação de cliente", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/pt-PT/commandPalette.json b/packages/web/public/i18n/locales/pt-PT/commandPalette.json index 3fd50eca..4f26f18e 100644 --- a/packages/web/public/i18n/locales/pt-PT/commandPalette.json +++ b/packages/web/public/i18n/locales/pt-PT/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Importar", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Entrar no modo DFU", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Desligar" diff --git a/packages/web/public/i18n/locales/pt-PT/common.json b/packages/web/public/i18n/locales/pt-PT/common.json index 49fd5ccc..1aa55494 100644 --- a/packages/web/public/i18n/locales/pt-PT/common.json +++ b/packages/web/public/i18n/locales/pt-PT/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "Okay", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Remover", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/pt-PT/dialog.json b/packages/web/public/i18n/locales/pt-PT/dialog.json index bd9d1502..2f057128 100644 --- a/packages/web/public/i18n/locales/pt-PT/dialog.json +++ b/packages/web/public/i18n/locales/pt-PT/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Generate QR Code" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Confirma?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/sv-SE/commandPalette.json b/packages/web/public/i18n/locales/sv-SE/commandPalette.json index b53e90be..84738969 100644 --- a/packages/web/public/i18n/locales/sv-SE/commandPalette.json +++ b/packages/web/public/i18n/locales/sv-SE/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "Import", "scheduleShutdown": "Schemalägg avstängning", - "scheduleReboot": "Schemalägg omstart", - "rebootToOtaMode": "Starta om till OTA-läge", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Töm noddatabasen", + "dfuMode": "Starta DFU-läge", "factoryResetDevice": "Fabriksåterställ enhet", "factoryResetConfig": "Fabriksåterställ konfigurationen", "disconnect": "Koppla från" diff --git a/packages/web/public/i18n/locales/sv-SE/common.json b/packages/web/public/i18n/locales/sv-SE/common.json index 577e927f..cf564a4e 100644 --- a/packages/web/public/i18n/locales/sv-SE/common.json +++ b/packages/web/public/i18n/locales/sv-SE/common.json @@ -17,7 +17,6 @@ "now": "Nu", "ok": "Okej", "print": "Skriv ut", - "rebootOtaNow": "Starta om till OTA-läge nu", "remove": "Ta bort", "requestNewKeys": "Begär nya nycklar", "requestPosition": "Begär position", @@ -137,5 +136,7 @@ "managed": "Åtminstone en administratörsnyckel krävs om noden fjärrhanteras.", "key": "En nyckel måste anges." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/sv-SE/dialog.json b/packages/web/public/i18n/locales/sv-SE/dialog.json index 39ce60b6..7f48e594 100644 --- a/packages/web/public/i18n/locales/sv-SE/dialog.json +++ b/packages/web/public/i18n/locales/sv-SE/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "Den här anslutningstypen kräver <0>. Använd en webbläsare som stöds, till exempel Chrome eller Edge.", + "requiresWebBluetooth": "Den här anslutningstypen kräver <0>Web Bluetooth. Använd en webbläsare som stöds, till exempel Chrome eller Edge.", + "requiresWebSerial": "Den här anslutningstypen kräver <0>Web Serial. Använd en webbläsare som stöds, till exempel Chrome eller Edge.", "requiresSecureContext": "Denna applikation kräver en <0>säker kontext. Anslut med HTTPS eller localhost.", "additionallyRequiresSecureContext": "Dessutom kräver den ett <0>säker kontext. Vänligen anslut med HTTPS eller localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Enhetens mätvärden:", "hardware": "Hårdvara: ", "lastHeard": "Senast hörd: ", - "nodeHexPrefix": "Nod-hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Nodnummer: ", "position": "Plats:", "role": "Roll: ", @@ -101,7 +102,12 @@ "title": "Nodinformation för {{identifier}}", "ignoreNode": "Ignorera nod", "removeNode": "Ta bort nod", - "unignoreNode": "Ta bort favoritmarkering" + "unignoreNode": "Ta bort favoritmarkering", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "Om du förlorar dina nycklar måste du återställa din enhet.", @@ -131,15 +137,15 @@ "sharableUrl": "Delbar URL", "title": "Generera QR-kod" }, - "rebootOta": { - "title": "Schemalägg omstart", - "description": "Starta om den anslutna noden till OTA- (Over-the-Air) läge efter en fördröjning.", - "enterDelay": "Ange fördröjning (sekunder)", - "scheduled": "Omstart har schemalagts" - }, "reboot": { - "title": "Schemalägg omstart", - "description": "Starta om den anslutna noden efter x minuter." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Omstart har schemalagts", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Ja, jag vet vad jag gör", "title": "Är du säker?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/tr-TR/commandPalette.json b/packages/web/public/i18n/locales/tr-TR/commandPalette.json index 4d3cd94e..e77bf380 100644 --- a/packages/web/public/i18n/locales/tr-TR/commandPalette.json +++ b/packages/web/public/i18n/locales/tr-TR/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Oluşturucu", "qrImport": "İçeri aktar", "scheduleShutdown": "Kapatmayı Planla", - "scheduleReboot": "Yeniden Başlatmayı Planla", - "rebootToOtaMode": "OTA Moduna Yeniden Başlat", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Node Veri Tabanını Sıfırla", + "dfuMode": "DFU Moduna Gir", "factoryResetDevice": "Cihazı Fabrika Ayarlarına Sıfırlayın", "factoryResetConfig": "Fabrika Ayarları Yapılandırması", "disconnect": "Bağlantıyı Kes" diff --git a/packages/web/public/i18n/locales/tr-TR/common.json b/packages/web/public/i18n/locales/tr-TR/common.json index 8eda746b..8458153d 100644 --- a/packages/web/public/i18n/locales/tr-TR/common.json +++ b/packages/web/public/i18n/locales/tr-TR/common.json @@ -17,7 +17,6 @@ "now": "Şimdi", "ok": "Tamam", "print": "Yazdır", - "rebootOtaNow": "Şimdi OTA Moduna Yeniden Başlat", "remove": "Kaldır", "requestNewKeys": "Yeni Anahtar İste", "requestPosition": "Konum İste", @@ -137,5 +136,7 @@ "managed": "Nodeun yönetilmesi için en az bir tane yönetici anahtarı gerekli.", "key": "Anahtar gerekli." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/tr-TR/dialog.json b/packages/web/public/i18n/locales/tr-TR/dialog.json index f43c6746..a434d1d4 100644 --- a/packages/web/public/i18n/locales/tr-TR/dialog.json +++ b/packages/web/public/i18n/locales/tr-TR/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Cihazları yüklerken bilinmeyen hata oluştu" }, "validation": { - "requiresFeatures": "Bu bağlantı tipi <0> gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.", + "requiresWebBluetooth": "Bu bağlantı tipi <0>Web Bluetooth gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.", + "requiresWebSerial": "Bu bağlantı tipi <0>Web Serial gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Donanım: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Rol: ", @@ -101,7 +102,12 @@ "title": "{{identifier}} Node Detayları", "ignoreNode": "Node yoksay", "removeNode": "Node sil", - "unignoreNode": "Node yoksaymayı bırak" + "unignoreNode": "Node yoksaymayı bırak", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "Eğer anahtarınızı kaybederseniz cihazınızı sıfırlamalısınız.", @@ -131,15 +137,15 @@ "sharableUrl": "Paylaşılabilir URL", "title": "QR kod oluştur" }, - "rebootOta": { - "title": "Yeniden Başlatmayı Planla", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Yeniden Başlatmayı Planla", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Evet, ne yaptığımı biliyorum", "title": "Emin misiniz?", "description": "Yönetilen Modun etkinleştirilmesi, istemci uygulamalarının (web istemcisi dahil) bir radyoya yapılandırma yazmasını engeller. Etkinleştirildikten sonra, radyo yapılandırmaları yalnızca Uzak Yönetici mesajları aracılığıyla değiştirilebilir. Bu ayar, uzak düğüm yönetimi için gerekli değildir." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/uk-UA/commandPalette.json b/packages/web/public/i18n/locales/uk-UA/commandPalette.json index 27210ff9..b0245288 100644 --- a/packages/web/public/i18n/locales/uk-UA/commandPalette.json +++ b/packages/web/public/i18n/locales/uk-UA/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Генератор", "qrImport": "Імпортувати", "scheduleShutdown": "Розклад вимкнення", - "scheduleReboot": "Розклад перезавантаження", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "Enter DFU Mode", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "Disconnect" diff --git a/packages/web/public/i18n/locales/uk-UA/common.json b/packages/web/public/i18n/locales/uk-UA/common.json index 74b3fb85..c6541320 100644 --- a/packages/web/public/i18n/locales/uk-UA/common.json +++ b/packages/web/public/i18n/locales/uk-UA/common.json @@ -17,7 +17,6 @@ "now": "Зараз", "ok": "Гаразд", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Видалити", "requestNewKeys": "Request New Keys", "requestPosition": "Запитати позицію", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/uk-UA/dialog.json b/packages/web/public/i18n/locales/uk-UA/dialog.json index 0905f211..a548135a 100644 --- a/packages/web/public/i18n/locales/uk-UA/dialog.json +++ b/packages/web/public/i18n/locales/uk-UA/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "Згенерувати QR-код" }, - "rebootOta": { - "title": "Запланувати перезавантаження", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Введіть затримку (сек)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Запланувати перезавантаження", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Я знаю, що роблю", "title": "Ви впевнені?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/public/i18n/locales/zh-CN/commandPalette.json b/packages/web/public/i18n/locales/zh-CN/commandPalette.json index ec411c5f..5d1e494c 100644 --- a/packages/web/public/i18n/locales/zh-CN/commandPalette.json +++ b/packages/web/public/i18n/locales/zh-CN/commandPalette.json @@ -33,9 +33,9 @@ "qrGenerator": "Generator", "qrImport": "导入", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", + "dfuMode": "进入 DFU 模式", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", "disconnect": "断开" diff --git a/packages/web/public/i18n/locales/zh-CN/common.json b/packages/web/public/i18n/locales/zh-CN/common.json index 16f13b76..9d3e0e65 100644 --- a/packages/web/public/i18n/locales/zh-CN/common.json +++ b/packages/web/public/i18n/locales/zh-CN/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "确定", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "移除", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", @@ -137,5 +136,7 @@ "managed": "At least one admin key is requred if the node is managed.", "key": "Key is required." } - } + }, + "yes": "Yes", + "no": "No" } diff --git a/packages/web/public/i18n/locales/zh-CN/dialog.json b/packages/web/public/i18n/locales/zh-CN/dialog.json index bdf743ed..9e6f48cf 100644 --- a/packages/web/public/i18n/locales/zh-CN/dialog.json +++ b/packages/web/public/i18n/locales/zh-CN/dialog.json @@ -75,7 +75,8 @@ "unknownErrorLoadingDevices": "Unknown error loading devices" }, "validation": { - "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "requiresWebBluetooth": "This connection type requires <0>Web Bluetooth. Please use a supported browser, like Chrome or Edge.", + "requiresWebSerial": "This connection type requires <0>Web Serial. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." } @@ -92,7 +93,7 @@ "deviceMetrics": "Device Metrics:", "hardware": "Hardware: ", "lastHeard": "Last Heard: ", - "nodeHexPrefix": "Node Hex: !", + "nodeHexPrefix": "Node Hex: ", "nodeNumber": "Node Number: ", "position": "Position:", "role": "Role: ", @@ -101,7 +102,12 @@ "title": "Node Details for {{identifier}}", "ignoreNode": "Ignore node", "removeNode": "Remove node", - "unignoreNode": "Unignore node" + "unignoreNode": "Unignore node", + "security": "Security:", + "publicKey": "Public Key: ", + "messageable": "Messageable: ", + "KeyManuallyVerifiedTrue": "Public Key has been manually verified", + "KeyManuallyVerifiedFalse": "Public Key is not manually verified" }, "pkiBackup": { "loseKeysWarning": "If you lose your keys, you will need to reset your device.", @@ -131,15 +137,15 @@ "sharableUrl": "Sharable URL", "title": "生成二维码" }, - "rebootOta": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", - "enterDelay": "Enter delay (sec)", - "scheduled": "Reboot has been scheduled" - }, "reboot": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.", + "ota": "Reboot into OTA mode", + "enterDelay": "Enter delay", + "scheduled": "Reboot has been scheduled", + "schedule": "Schedule reboot", + "now": "Reboot now", + "cancel": "Cancel scheduled reboot" }, "refreshKeys": { "description": { @@ -178,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "是否确认?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "客户端通知", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 12cfca58..d0eb9ebe 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -3,6 +3,7 @@ import { CommandPalette } from "@components/CommandPalette/index.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; +import { Toaster } from "@components/Toaster.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import Footer from "@components/UI/Footer.tsx"; import { useTheme } from "@core/hooks/useTheme.ts"; @@ -12,15 +13,13 @@ import { Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { ErrorBoundary } from "react-error-boundary"; import { MapProvider } from "react-map-gl/maplibre"; -// Import feature flags and dev overrides -import "@core/services/dev-overrides.ts"; export function App() { const { getDevice } = useDeviceStore(); - const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = + const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } = useAppStore(); - const device = getDevice(selectedDevice); + const device = getDevice(selectedDeviceId); // Sets up light/dark mode based on user preferences or system settings useTheme(); @@ -33,8 +32,9 @@ export function App() { setConnectDialogOpen(open); }} /> + - +
{ +export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => { return ( - {children} + + {children} + ); }; diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx index a07ac46c..e3e2dc63 100644 --- a/packages/web/src/components/CommandPalette/index.tsx +++ b/packages/web/src/components/CommandPalette/index.tsx @@ -8,7 +8,12 @@ import { CommandList, } from "@components/UI/Command.tsx"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; -import { useAppStore, useDevice, useDeviceStore } from "@core/stores"; +import { + useAppStore, + useDevice, + useDeviceStore, + useNodeDB, +} from "@core/stores"; import { cn } from "@core/utils/cn.ts"; import { useNavigate } from "@tanstack/react-router"; import { useCommandState } from "cmdk"; @@ -65,7 +70,8 @@ export const CommandPalette = () => { setSelectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); - const { setDialogOpen, getNode, connection } = useDevice(); + const { setDialogOpen, connection } = useDevice(); + const { getNode, removeAllNodeErrors, removeAllNodes } = useNodeDB(); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: "pinnedCommandMenuGroups", }); @@ -201,6 +207,8 @@ export const CommandPalette = () => { icon: TrashIcon, action() { connection?.resetNodes(); + removeAllNodeErrors(); + removeAllNodes(true); }, }, { @@ -217,6 +225,8 @@ export const CommandPalette = () => { icon: FactoryIcon, action() { connection?.factoryResetDevice(); + removeAllNodeErrors(); + removeAllNodes(); }, }, { diff --git a/packages/web/src/components/Dialog/DeviceNameDialog.tsx b/packages/web/src/components/Dialog/DeviceNameDialog.tsx index ee8b15ba..055d11f8 100644 --- a/packages/web/src/components/Dialog/DeviceNameDialog.tsx +++ b/packages/web/src/components/Dialog/DeviceNameDialog.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useDevice } from "@core/stores"; +import { useDevice, useNodeDB } from "@core/stores"; import { zodResolver } from "@hookform/resolvers/zod"; import { Protobuf } from "@meshtastic/core"; import { useForm } from "react-hook-form"; @@ -33,7 +33,8 @@ export const DeviceNameDialog = ({ onOpenChange, }: DeviceNameDialogProps) => { const { t } = useTranslation("dialog"); - const { hardware, getNode, connection } = useDevice(); + const { hardware, connection } = useDevice(); + const { getNode } = useNodeDB(); const myNode = getNode(hardware.myNodeNum); const defaultValues = { diff --git a/packages/web/src/components/Dialog/LocationResponseDialog.tsx b/packages/web/src/components/Dialog/LocationResponseDialog.tsx index e501da25..0fa9aff9 100644 --- a/packages/web/src/components/Dialog/LocationResponseDialog.tsx +++ b/packages/web/src/components/Dialog/LocationResponseDialog.tsx @@ -1,4 +1,4 @@ -import { useDevice } from "@core/stores"; +import { useNodeDB } from "@core/stores"; import type { Protobuf, Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; @@ -23,7 +23,7 @@ export const LocationResponseDialog = ({ onOpenChange, }: LocationResponseDialogProps) => { const { t } = useTranslation("dialog"); - const { getNode } = useDevice(); + const { getNode } = useNodeDB(); const from = getNode(location?.from ?? 0); const longName = diff --git a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index a6b9104e..a55e6794 100644 --- a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -27,7 +27,7 @@ import { import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts"; import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts"; import { toast } from "@core/hooks/useToast.ts"; -import { useAppStore, useDevice } from "@core/stores"; +import { useAppStore, useDevice, useNodeDB } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; import { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; @@ -55,7 +55,8 @@ export const NodeDetailsDialog = ({ onOpenChange, }: NodeDetailsDialogProps) => { const { t } = useTranslation("dialog"); - const { setDialogOpen, connection, getNode } = useDevice(); + const { setDialogOpen, connection } = useDevice(); + const { getNode } = useNodeDB(); const navigate = useNavigate(); const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore(); const { updateFavorite } = useFavoriteNode(); diff --git a/packages/web/src/components/Dialog/PKIBackupDialog.tsx b/packages/web/src/components/Dialog/PKIBackupDialog.tsx index a3f296af..df68b59d 100644 --- a/packages/web/src/components/Dialog/PKIBackupDialog.tsx +++ b/packages/web/src/components/Dialog/PKIBackupDialog.tsx @@ -1,3 +1,4 @@ +import { Button } from "@components/UI/Button.tsx"; import { Dialog, DialogClose, @@ -7,12 +8,11 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; +import { useDevice, useNodeDB } from "@core/stores"; import { fromByteArray } from "base64-js"; import { DownloadIcon, PrinterIcon } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; -import { useDevice } from "../../core/stores"; -import { Button } from "../UI/Button.tsx"; export interface PkiBackupDialogProps { open: boolean; @@ -24,7 +24,8 @@ export const PkiBackupDialog = ({ onOpenChange, }: PkiBackupDialogProps) => { const { t } = useTranslation("dialog"); - const { config, setDialogOpen, getMyNode } = useDevice(); + const { config, setDialogOpen } = useDevice(); + const { getMyNode } = useNodeDB(); const privateKey = config.security?.privateKey; const publicKey = config.security?.publicKey; diff --git a/packages/web/src/components/Dialog/RebootDialog.test.tsx b/packages/web/src/components/Dialog/RebootDialog.test.tsx index 85cf813c..bf88be9c 100644 --- a/packages/web/src/components/Dialog/RebootDialog.test.tsx +++ b/packages/web/src/components/Dialog/RebootDialog.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, act } from "@testing-library/react"; import type { ButtonHTMLAttributes, ClassAttributes, @@ -89,7 +89,9 @@ describe("RebootDialog", () => { render( {}} />); // Schedule non-OTA reboot - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(5); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -97,7 +99,9 @@ describe("RebootDialog", () => { rebootOtaMock.mockClear(); // Cancel scheduled - fireEvent.click(screen.getByTestId("cancelRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("cancelRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(-1); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -105,8 +109,12 @@ describe("RebootDialog", () => { rebootOtaMock.mockClear(); // Schedule OTA reboot - fireEvent.click(screen.getByText(/reboot into ota mode/i)); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByText(/reboot into ota mode/i)); + }); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootOtaMock).toHaveBeenCalledWith(5); expect(rebootMock).not.toHaveBeenCalled(); }); @@ -115,17 +123,23 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { - target: { value: "3" }, + act(() => { + fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { + target: { value: "3" }, + }); }); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); expect(rebootMock).toHaveBeenCalledWith(3); expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); - vi.advanceTimersByTime(3000); + act(() => { + vi.advanceTimersByTime(3000); + }); expect(onOpenChangeMock).toHaveBeenCalledWith(false); @@ -135,7 +149,9 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: /reboot now/i })); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /reboot now/i })); + }); expect(rebootMock).toHaveBeenCalledWith(0); expect(onOpenChangeMock).toHaveBeenCalledWith(false); @@ -148,9 +164,13 @@ describe("RebootDialog", () => { render(); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); - vi.advanceTimersByTime(5000); + act(() => { + vi.advanceTimersByTime(5000); + }); expect(rebootMock).not.toHaveBeenCalled(); expect(rebootOtaMock).not.toHaveBeenCalled(); @@ -162,13 +182,19 @@ describe("RebootDialog", () => { const onOpenChangeMock = vi.fn(); render(); - fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { - target: { value: "4" }, + act(() => { + fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { + target: { value: "4" }, + }); + }); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); }); - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); expect(rebootMock).toHaveBeenCalledWith(4); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + }); expect(rebootMock).toHaveBeenCalledWith(-1); expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument(); }); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx index 6a87ccc5..d9501fc6 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx @@ -1,4 +1,4 @@ -import { DeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; +import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; import { render } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; @@ -37,11 +37,6 @@ test("does not render dialog if no error exists for active chat", () => { useDeviceStore.getState().addDevice(deviceId); - const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); - if (!currentDeviceState) { - throw new Error("Device not found"); - } - mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); mockUseRefreshKeysDialog.mockReturnValue({ handleCloseDialog: vi.fn(), @@ -49,9 +44,9 @@ test("does not render dialog if no error exists for active chat", () => { }); const { container } = render( - + - , + , ); expect(container.firstChild).toBeNull(); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 388f6361..19a51e17 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -6,7 +6,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useDevice, useMessageStore } from "@core/stores"; +import { useMessageStore, useNodeDB } from "@core/stores"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; @@ -22,7 +22,8 @@ export const RefreshKeysDialog = ({ }: RefreshKeysDialogProps) => { const { t } = useTranslation("dialog"); const { activeChat } = useMessageStore(); - const { nodeErrors, getNode } = useDevice(); + const { nodeErrors, getNode } = useNodeDB(); + const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const nodeErrorNum = nodeErrors.get(activeChat); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index 822abdca..aa7352cb 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,9 +1,9 @@ -import { useDevice, useMessageStore } from "@core/stores"; +import { useDevice, useMessageStore, useNodeDB } from "@core/stores"; import { useCallback } from "react"; export function useRefreshKeysDialog() { - const { removeNode, setDialogOpen, clearNodeError, getNodeError } = - useDevice(); + const { setDialogOpen } = useDevice(); + const { removeNode, clearNodeError, getNodeError } = useNodeDB(); const { activeChat } = useMessageStore(); const handleCloseDialog = useCallback(() => { diff --git a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx index 2bb7e181..9574fa16 100644 --- a/packages/web/src/components/Dialog/RemoveNodeDialog.tsx +++ b/packages/web/src/components/Dialog/RemoveNodeDialog.tsx @@ -9,7 +9,7 @@ import { DialogTitle, } from "@components/UI/Dialog.tsx"; import { Label } from "@components/UI/Label.tsx"; -import { useAppStore, useDevice } from "@core/stores"; +import { useAppStore, useDevice, useNodeDB } from "@core/stores"; import { useTranslation } from "react-i18next"; export interface RemoveNodeDialogProps { @@ -22,7 +22,8 @@ export const RemoveNodeDialog = ({ onOpenChange, }: RemoveNodeDialogProps) => { const { t } = useTranslation("dialog"); - const { connection, getNode, removeNode } = useDevice(); + const { connection } = useDevice(); + const { getNode, removeNode } = useNodeDB(); const { nodeNumToBeRemoved } = useAppStore(); const onSubmit = () => { diff --git a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx index 4748e2d4..cf27fe2d 100644 --- a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx +++ b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx @@ -1,4 +1,4 @@ -import { useDevice } from "@core/stores"; +import { useNodeDB } from "@core/stores"; import type { Protobuf, Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; @@ -25,7 +25,7 @@ export const TracerouteResponseDialog = ({ onOpenChange, }: TracerouteResponseDialogProps) => { const { t } = useTranslation("dialog"); - const { getNode } = useDevice(); + const { getNode } = useNodeDB(); const route: number[] = traceroute?.data.route ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? []; const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4); diff --git a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index a2227763..cc69ef71 100644 --- a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -36,7 +36,10 @@ const mockDevice = { setDialogOpen: vi.fn(), }; -vi.mock("@core/stores/deviceStore", () => ({ +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 123 }, + }, useDevice: () => ({ setDialogOpen: mockDevice.setDialogOpen, }), @@ -85,7 +88,7 @@ describe("useUnsafeRolesDialog", () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[0], + UNSAFE_ROLES[0]!, ); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith( @@ -97,7 +100,7 @@ describe("useUnsafeRolesDialog", () => { expect.any(Function), ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); const validationResult = await validationPromise; @@ -111,9 +114,9 @@ describe("useUnsafeRolesDialog", () => { it("should resolve with false when user dismisses the dialog", async () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[0], + UNSAFE_ROLES[0]!, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "dismiss" }); const validationResult = await validationPromise; @@ -128,9 +131,9 @@ describe("useUnsafeRolesDialog", () => { const { result } = renderUnsafeRolesHook(); const validationPromise = result.current.validateRoleSelection( - UNSAFE_ROLES[1], + UNSAFE_ROLES[1]!, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); await validationPromise; @@ -157,7 +160,7 @@ describe("useUnsafeRolesDialog", () => { true, ); - const onHandler = (eventBus.on as Mock).mock.calls[0][1]; + const onHandler = (eventBus.on as Mock).mock.calls[0]![1]; onHandler({ action: "confirm" }); const validationResult = await validationPromise; diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx index 40a5d307..9d50b64c 100644 --- a/packages/web/src/components/PageComponents/Connect/BLE.tsx +++ b/packages/web/src/components/PageComponents/Connect/BLE.tsx @@ -1,6 +1,11 @@ import { Mono } from "@components/generic/Mono.tsx"; import { Button } from "@components/UI/Button.tsx"; -import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores"; +import { + useAppStore, + useDeviceStore, + useMessageStore, + useNodeDBStore, +} from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; @@ -12,7 +17,9 @@ import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx"; export const BLE = ({ closeDialog }: TabElementProps) => { const [connectionInProgress, setConnectionInProgress] = useState(false); const [bleDevices, setBleDevices] = useState([]); + const { addDevice } = useDeviceStore(); + const { addNodeDB } = useNodeDBStore(); const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -29,11 +36,13 @@ export const BLE = ({ closeDialog }: TabElementProps) => { const id = randId(); const transport = await TransportWebBluetooth.createFromDevice(bleDevice); const device = addDevice(id); + const nodeDB = addNodeDB(id); + const connection = new MeshDevice(transport, id); connection.configure(); setSelectedDevice(id); device.addConnection(connection); - subscribeAll(device, connection, messageStore); + subscribeAll(device, connection, messageStore, nodeDB); const HEARTBEAT_INTERVAL = 5 * 60 * 1000; connection.setHeartbeatInterval(HEARTBEAT_INTERVAL); diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx index c9bae27d..8c0733f1 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx @@ -9,7 +9,10 @@ vi.mock("@core/stores", () => ({ useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })), })), - useMessageStore: vi.fn() + useMessageStore: vi.fn(), + useNodeDBStore: vi.fn(() => ({ + addNodeDB: vi.fn(), + })), })); vi.mock("@core/utils/randId.ts", () => ({ diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx index 566ef36d..b4ac3591 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.tsx @@ -4,7 +4,12 @@ import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { Link } from "@components/UI/Typography/Link.tsx"; -import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores"; +import { + useAppStore, + useDeviceStore, + useMessageStore, + useNodeDBStore, +} from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; @@ -25,6 +30,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const isURLHTTPS = location.protocol === "https:"; const { addDevice } = useDeviceStore(); + const { addNodeDB } = useNodeDBStore(); const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); @@ -56,11 +62,13 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const id = randId(); const transport = await TransportHTTP.create(data.ip, data.tls); const device = addDevice(id); + const nodeDB = addNodeDB(id); + const connection = new MeshDevice(transport, id); connection.configure(); setSelectedDevice(id); device.addConnection(connection); - subscribeAll(device, connection, messageStore); + subscribeAll(device, connection, messageStore, nodeDB); closeDialog(); } catch (error) { if (error instanceof Error) { diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx index 2a671c7c..4015606a 100644 --- a/packages/web/src/components/PageComponents/Connect/Serial.tsx +++ b/packages/web/src/components/PageComponents/Connect/Serial.tsx @@ -1,6 +1,11 @@ import { Mono } from "@components/generic/Mono.tsx"; import { Button } from "@components/UI/Button.tsx"; -import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores"; +import { + useAppStore, + useDeviceStore, + useMessageStore, + useNodeDBStore, +} from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; @@ -12,7 +17,9 @@ import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx"; export const Serial = ({ closeDialog }: TabElementProps) => { const [connectionInProgress, setConnectionInProgress] = useState(false); const [serialPorts, setSerialPorts] = useState([]); + const { addDevice } = useDeviceStore(); + const { addNodeDB } = useNodeDBStore(); const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -34,12 +41,14 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const onConnect = async (port: SerialPort) => { const id = randId(); const device = addDevice(id); + const nodeDB = addNodeDB(id); + setSelectedDevice(id); const transport = await TransportWebSerial.createFromPort(port); const connection = new MeshDevice(transport, id); connection.configure(); device.addConnection(connection); - subscribeAll(device, connection, messageStore); + subscribeAll(device, connection, messageStore, nodeDB); const HEARTBEAT_INTERVAL = 5 * 60 * 1000; connection.setHeartbeatInterval(HEARTBEAT_INTERVAL); diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index 3a76ac55..4f75ccef 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -6,7 +6,12 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { MessageState, useDevice, useMessageStore } from "@core/stores"; +import { + MessageState, + useDevice, + useMessageStore, + useNodeDB, +} from "@core/stores"; import type { Message } from "@core/stores/messageStore/types.ts"; import { cn } from "@core/utils/cn.ts"; import { type Protobuf, Types } from "@meshtastic/core"; @@ -47,7 +52,8 @@ interface MessageItemProps { } export const MessageItem = ({ message }: MessageItemProps) => { - const { config, getNode } = useDevice(); + const { config } = useDevice(); + const { getNode } = useNodeDB(); const { getMyNodeNum } = useMessageStore(); const { t, i18n } = useTranslation("messages"); diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx index 153b5b61..b3a40da7 100644 --- a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,6 +1,6 @@ import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; -import { mockDeviceStore } from "@core/stores/deviceStore/deviceStore.mock.ts"; -import { useDevice } from "@core/stores"; +import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts"; +import { useNodeDB } from "@core/stores"; import { Protobuf } from "@meshtastic/core"; import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -64,9 +64,10 @@ describe("TraceRoute", () => { ]); beforeEach(() => { + vi.resetAllMocks(); - vi.mocked(useDevice).mockReturnValue({ - ...mockDeviceStore, + vi.mocked(useNodeDB).mockReturnValue({ + ...mockNodeDBStore, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx index 2449d291..a8f64498 100644 --- a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx @@ -1,4 +1,4 @@ -import { useDevice } from "@core/stores"; +import { useNodeDB } from "@core/stores"; import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; @@ -23,7 +23,7 @@ interface RoutePathProps { } const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => { - const { getNode } = useDevice(); + const { getNode } = useNodeDB(); const { t } = useTranslation(); return ( diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index dfd32c1f..d16bc09d 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -2,7 +2,13 @@ import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { Spinner } from "@components/UI/Spinner.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { type Page, useAppStore, useDevice, useSidebar } from "@core/stores"; +import { + type Page, + useAppStore, + useDevice, + useNodeDB, + useSidebar, +} from "@core/stores"; import { cn } from "@core/utils/cn.ts"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { @@ -61,14 +67,8 @@ const CollapseToggleButton = () => { }; export const Sidebar = ({ children }: SidebarProps) => { - const { - hardware, - getNode, - getNodesLength, - metadata, - unreadCounts, - setDialogOpen, - } = useDevice(); + const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice(); + const { getNode, getNodesLength } = useNodeDB(); const { setCommandPaletteOpen } = useAppStore(); const myNode = getNode(hardware.myNodeNum); const { isCollapsed } = useSidebar(); diff --git a/packages/web/src/core/hooks/useFavoriteNode.test.ts b/packages/web/src/core/hooks/useFavoriteNode.test.ts index 6b2c0591..76063533 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.test.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.test.ts @@ -14,12 +14,19 @@ const mockNode = { const mockUpdateFavorite = vi.fn(); const mockGetNode = vi.fn(() => mockNode); const mockToast = vi.fn(); +const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ - useDevice: () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useNodeDB: () => ({ updateFavorite: mockUpdateFavorite, getNode: mockGetNode, }), + useDevice: () => ({ + sendAdminMessage: mockSendAdminMessage, + }), })); vi.mock("@core/hooks/useToast.ts", () => ({ diff --git a/packages/web/src/core/hooks/useFavoriteNode.ts b/packages/web/src/core/hooks/useFavoriteNode.ts index d3358766..4c8faf08 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.ts @@ -1,5 +1,7 @@ +import { create } from "@bufbuild/protobuf"; import { useToast } from "@core/hooks/useToast.ts"; -import { useDevice } from "@core/stores"; +import { useDevice, useNodeDB } from "@core/stores"; +import { Protobuf } from "@meshtastic/core"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -9,7 +11,8 @@ interface FavoriteNodeOptions { } export function useFavoriteNode() { - const { updateFavorite, getNode } = useDevice(); + const { sendAdminMessage } = useDevice(); + const { getNode, updateFavorite } = useNodeDB(); const { t } = useTranslation(); const { toast } = useToast(); @@ -20,6 +23,16 @@ export function useFavoriteNode() { return; } + sendAdminMessage( + create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode", + value: nodeNum, + }, + }), + ); + + // TODO: Wait for response before changing the store updateFavorite(nodeNum, isFavorite); toast({ @@ -34,7 +47,7 @@ export function useFavoriteNode() { }), }); }, - [updateFavorite, getNode, t, toast], + [updateFavorite, sendAdminMessage, getNode, t, toast], ); return { updateFavorite: updateFavoriteCB }; diff --git a/packages/web/src/core/hooks/useIgnoreNode.test.ts b/packages/web/src/core/hooks/useIgnoreNode.test.ts index 5a7904c8..deb5b143 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.test.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.test.ts @@ -14,12 +14,19 @@ const mockNode = { const mockUpdateIgnore = vi.fn(); const mockGetNode = vi.fn(() => mockNode); const mockToast = vi.fn(); +const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ - useDevice: () => ({ - updateIgnored: mockUpdateIgnore, + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useNodeDB: () => ({ + updateIgnore: mockUpdateIgnore, getNode: mockGetNode, }), + useDevice: () => ({ + sendAdminMessage: mockSendAdminMessage, + }), })); vi.mock("@core/hooks/useToast.ts", () => ({ diff --git a/packages/web/src/core/hooks/useIgnoreNode.ts b/packages/web/src/core/hooks/useIgnoreNode.ts index 05e6cdc7..0a2dc099 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.ts @@ -1,5 +1,7 @@ +import { create } from "@bufbuild/protobuf"; import { useToast } from "@core/hooks/useToast.ts"; -import { useDevice } from "@core/stores"; +import { useDevice, useNodeDB } from "@core/stores"; +import { Protobuf } from "@meshtastic/core"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -9,7 +11,9 @@ interface IgnoreNodeOptions { } export function useIgnoreNode() { - const { updateIgnored, getNode } = useDevice(); + const { sendAdminMessage } = useDevice(); + const { getNode, updateIgnore } = useNodeDB(); + const { t } = useTranslation(); const { toast } = useToast(); @@ -21,7 +25,17 @@ export function useIgnoreNode() { return; } - updateIgnored(nodeNum, isIgnored); + sendAdminMessage( + create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode", + value: nodeNum, + }, + }), + ); + + // TODO: Wait for response before changing the store + updateIgnore(nodeNum, isIgnored); toast({ title: t("toast.ignoreNode.title", { @@ -35,7 +49,7 @@ export function useIgnoreNode() { }), }); }, - [updateIgnored, getNode, t, toast], + [sendAdminMessage, updateIgnore, getNode, t, toast], ); return { updateIgnored: updateIgnoredCB }; diff --git a/packages/web/src/core/stores/appStore/index.ts b/packages/web/src/core/stores/appStore/index.ts index 0f6eb039..ebd5ea63 100644 --- a/packages/web/src/core/stores/appStore/index.ts +++ b/packages/web/src/core/stores/appStore/index.ts @@ -9,7 +9,7 @@ export interface RasterSource { } interface AppState { - selectedDevice: number; + selectedDeviceId: number; devices: { id: number; num: number; @@ -42,7 +42,7 @@ interface AppState { } export const useAppStore = create()((set, get) => ({ - selectedDevice: 0, + selectedDeviceId: 0, devices: [], currentPage: "messages", rasterSources: [], @@ -76,7 +76,7 @@ export const useAppStore = create()((set, get) => ({ }, setSelectedDevice: (deviceId) => set(() => ({ - selectedDevice: deviceId, + selectedDeviceId: deviceId, })), addDevice: (device) => set((state) => ({ diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index 325cd8c2..06040336 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -24,14 +24,12 @@ export const mockDeviceStore: Device = { hardware: {} as Protobuf.Mesh.MyNodeInfo, metadata: new Map(), traceroutes: new Map(), - nodeErrors: new Map(), connection: undefined, activeNode: 0, waypoints: [], pendingSettingsChanges: false, messageDraft: "", unreadCounts: new Map(), - nodesMap: new Map(), dialog: { import: false, QR: false, @@ -68,30 +66,15 @@ export const mockDeviceStore: Device = { setPendingSettingsChanges: vi.fn(), addChannel: vi.fn(), addWaypoint: vi.fn(), - addNodeInfo: vi.fn(), - addUser: vi.fn(), - addPosition: vi.fn(), addConnection: vi.fn(), addTraceRoute: vi.fn(), addMetadata: vi.fn(), - removeNode: vi.fn(), setDialogOpen: vi.fn(), getDialogOpen: vi.fn().mockReturnValue(false), - processPacket: vi.fn(), setMessageDraft: vi.fn(), - setNodeError: vi.fn(), - clearNodeError: vi.fn(), - getNodeError: vi.fn().mockReturnValue(undefined), - hasNodeError: vi.fn().mockReturnValue(false), incrementUnread: vi.fn(), resetUnread: vi.fn(), - getNodes: vi.fn().mockReturnValue([]), - getNodesLength: vi.fn().mockReturnValue(0), - getNode: vi.fn().mockReturnValue(undefined), - getMyNode: vi.fn(), sendAdminMessage: vi.fn(), - updateFavorite: vi.fn(), - updateIgnored: vi.fn(), addClientNotification: vi.fn(), removeClientNotification: vi.fn(), getClientNotification: vi.fn(), diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index ddf420ef..61569f09 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -1,7 +1,6 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { type MeshDevice, Protobuf, Types } from "@meshtastic/core"; import { produce } from "immer"; -import { createContext, useContext } from "react"; import { create as createStore } from "zustand"; export type Page = "messages" | "map" | "config" | "channels" | "nodes"; @@ -14,10 +13,6 @@ export interface ProcessPacketParams { export type DialogVariant = keyof Device["dialog"]; -type NodeError = { - node: number; - error: string; -}; export type ValidConfigType = Exclude< Protobuf.Config.Config["payloadVariant"]["case"], "deviceUi" | "sessionkey" | undefined @@ -42,14 +37,12 @@ export interface Device { number, Types.PacketMetadata[] >; - nodeErrors: Map; connection?: MeshDevice; activeNode: number; waypoints: Protobuf.Mesh.Waypoint[]; pendingSettingsChanges: boolean; messageDraft: string; unreadCounts: Map; - nodesMap: Map; // dont access directly, use getNodes, or getNode dialog: { import: boolean; QR: boolean; @@ -103,36 +96,19 @@ export interface Device { setPendingSettingsChanges: (state: boolean) => void; addChannel: (channel: Protobuf.Channel.Channel) => void; addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void; - addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void; - addUser: (user: Types.PacketMetadata) => void; - addPosition: (position: Types.PacketMetadata) => void; addConnection: (connection: MeshDevice) => void; addTraceRoute: ( traceroute: Types.PacketMetadata, ) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; - removeNode: (nodeNum: number) => void; setDialogOpen: (dialog: DialogVariant, open: boolean) => void; getDialogOpen: (dialog: DialogVariant) => boolean; - processPacket: (data: ProcessPacketParams) => void; setMessageDraft: (message: string) => void; - setNodeError: (nodeNum: number, error: string) => void; - clearNodeError: (nodeNum: number) => void; - getNodeError: (nodeNum: number) => NodeError | undefined; - hasNodeError: (nodeNum: number) => boolean; incrementUnread: (nodeNum: number) => void; resetUnread: (nodeNum: number) => void; getUnreadCount: (nodeNum: number) => number; getAllUnreadCount: () => number; - getNodes: ( - filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, - ) => Protobuf.Mesh.NodeInfo[]; - getNodesLength: () => number; - getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; - getMyNode: () => Protobuf.Mesh.NodeInfo; sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void; - updateFavorite: (nodeNum: number, isFavorite: boolean) => void; - updateIgnored: (nodeNum: number, isIgnored: boolean) => void; addClientNotification: ( clientNotificationPacket: Protobuf.Mesh.ClientNotification, ) => void; @@ -193,9 +169,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, pendingSettingsChanges: false, messageDraft: "", - nodeErrors: new Map(), unreadCounts: new Map(), - nodesMap: new Map(), clientNotifications: [], setStatus: (status: Types.DeviceStatusEnum) => { @@ -578,18 +552,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - addNodeInfo: (nodeInfo) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - - if (!device) { - return; - } - device.nodesMap.set(nodeInfo.num, nodeInfo); - }), - ); - }, setActiveNode: (node) => { set( produce((draft) => { @@ -600,38 +562,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - addUser: (user) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const currentNode = - device.nodesMap.get(user.from) ?? - create(Protobuf.Mesh.NodeInfoSchema); - currentNode.user = user.data; - currentNode.num = user.from; - device.nodesMap.set(user.from, currentNode); - }), - ); - }, - addPosition: (position) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const currentNode = - device.nodesMap.get(position.from) ?? - create(Protobuf.Mesh.NodeInfoSchema); - currentNode.position = position.data; - currentNode.num = position.from; - device.nodesMap.set(position.from, currentNode); - }), - ); - }, addConnection: (connection) => { set( produce((draft) => { @@ -665,17 +595,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - removeNode: (nodeNum: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - device.nodesMap.delete(nodeNum); - }), - ); - }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( produce((draft) => { @@ -693,31 +612,7 @@ export const useDeviceStore = createStore((set, get) => ({ } return device.dialog[dialog]; }, - processPacket(data: ProcessPacketParams) { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const node = device.nodesMap.get(data.from); - if (node) { - node.lastHeard = data.time; - node.snr = data.snr; - device.nodesMap.set(data.from, node); - } else { - device.nodesMap.set( - data.from, - create(Protobuf.Mesh.NodeInfoSchema, { - num: data.from, - lastHeard: data.time, - snr: data.snr, - }), - ); - } - }), - ); - }, + setMessageDraft: (message: string) => { set( produce((draft) => { @@ -728,40 +623,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setNodeError: (nodeNum: number, error: string) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.nodeErrors.set(nodeNum, { node: nodeNum, error }); - } - }), - ); - }, - clearNodeError: (nodeNum: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.nodeErrors.delete(nodeNum); - } - }), - ); - }, - getNodeError: (nodeNum: number) => { - const device = get().devices.get(id); - if (!device) { - throw new Error(`Device ${id} not found`); - } - return device.nodeErrors.get(nodeNum); - }, - hasNodeError: (nodeNum: number) => { - const device = get().devices.get(id); - if (!device) { - throw new Error(`Device ${id} not found`); - } - return device.nodeErrors.has(nodeNum); - }, incrementUnread: (nodeNum: number) => { set( produce((draft) => { @@ -806,48 +667,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - getNodes: ( - filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, - ): Protobuf.Mesh.NodeInfo[] => { - const device = get().devices.get(id); - if (!device) { - return []; - } - const allNodes = Array.from(device.nodesMap.values()).filter( - (node) => node.num !== get().devices.get(id)?.hardware.myNodeNum, - ); - if (filter) { - return allNodes.filter(filter); - } - return allNodes; - }, - getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { - const device = get().devices.get(id); - if (!device) { - return; - } - if (!device.nodesMap.has(nodeNum)) { - return undefined; - } - return device.nodesMap.get(nodeNum); - }, - getMyNode: (): Protobuf.Mesh.NodeInfo => { - const device = get().devices.get(id); - if (!device) { - throw new Error(`Device ${id} not found`); - } - return ( - device.nodesMap.get(device.hardware.myNodeNum) ?? - create(Protobuf.Mesh.NodeInfoSchema) - ); - }, - getNodesLength: () => { - const device = get().devices.get(id); - if (!device) { - return 0; - } - return device.nodesMap.size; - }, sendAdminMessage(message: Protobuf.Admin.AdminMessage) { const device = get().devices.get(id); @@ -862,64 +681,6 @@ export const useDeviceStore = createStore((set, get) => ({ ); }, - updateFavorite(nodeNum: number, isFavorite: boolean) { - const device = get().devices.get(id); - if (!device) { - return; - } - const node = device?.nodesMap.get(nodeNum); - if (!node) { - return; - } - - device.sendAdminMessage( - create(Protobuf.Admin.AdminMessageSchema, { - payloadVariant: { - case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode", - value: nodeNum, - }, - }), - ); - - set( - produce((draft) => { - const device = draft.devices.get(id); - const node = device?.nodesMap.get(nodeNum); - if (node) { - node.isFavorite = isFavorite; - } - }), - ); - }, - updateIgnored(nodeNum: number, isIgnored: boolean) { - const device = get().devices.get(id); - if (!device) { - return; - } - const node = device?.nodesMap.get(nodeNum); - if (!node) { - return; - } - - device.sendAdminMessage( - create(Protobuf.Admin.AdminMessageSchema, { - payloadVariant: { - case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode", - value: nodeNum, - }, - }), - ); - - set( - produce((draft) => { - const device = draft.devices.get(id); - const node = device?.nodesMap.get(nodeNum); - if (node) { - node.isIgnored = isIgnored; - } - }), - ); - }, addClientNotification: ( clientNotificationPacket: Protobuf.Mesh.ClientNotification, ) => { @@ -974,13 +735,3 @@ export const useDeviceStore = createStore((set, get) => ({ getDevice: (id) => get().devices.get(id), })); - -export const DeviceContext = createContext(undefined); - -export const useDevice = (): Device => { - const context = useContext(DeviceContext); - if (context === undefined) { - throw new Error("useDevice must be used within a DeviceProvider"); - } - return context; -}; diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index f10e6042..e6c73d19 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -1,22 +1,45 @@ -export { useAppStore } from "@core/stores/appStore"; +import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext"; +import { type Device, useDeviceStore } from "@core/stores/deviceStore"; +import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; +export { + CurrentDeviceContext, + type DeviceContext, + useDeviceContext, +} from "@app/core/stores/utils/useDeviceContext"; +export { useAppStore } from "@core/stores/appStore"; export { type Device, - DeviceContext, - useDevice, + type Page, useDeviceStore, type ValidConfigType, type ValidModuleConfigType, } from "@core/stores/deviceStore"; - export { MessageState, type MessageStore, MessageType, - useMessageStore, + useMessageStore, // TODO: Bring hook into this file } from "@core/stores/messageStore"; - +export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; export { SidebarProvider, - useSidebar, + useSidebar, // TODO: Bring hook into this file } from "@core/stores/sidebarStore"; + +// Define hooks to access the stores +export const useNodeDB = (): NodeDB => { + const { deviceId } = useDeviceContext(); + const nodeDB = useNodeDBStore( + (s) => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId), + ); + return nodeDB; +}; +export const useDevice = (): Device => { + const { deviceId } = useDeviceContext(); + + const device = useDeviceStore( + (s) => s.getDevice(deviceId) ?? s.addDevice(deviceId), + ); + return device; +}; diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts new file mode 100644 index 00000000..c5cdb0d2 --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -0,0 +1,440 @@ +import { create } from "@bufbuild/protobuf"; +import { featureFlags } from "@core/services/featureFlags"; +import { createStorage } from "@core/stores/utils/indexDB.ts"; +import { Protobuf, type Types } from "@meshtastic/core"; +import { produce } from "immer"; +import { create as createStore, type StateCreator } from "zustand"; +import { type PersistOptions, persist } from "zustand/middleware"; +import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types"; + +const CURRENT_STORE_VERSION = 0; +const NODEDB_RETENTION_NUM = 10; + +export interface NodeDB { + id: number; + myNodeNum: number | undefined; + nodeMap: Map; + nodeErrors: Map; + + addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void; + removeNode: (nodeNum: number) => void; + removeAllNodes: (keepMyNode?: boolean) => void; + processPacket: (data: ProcessPacketParams) => void; + addUser: (user: Types.PacketMetadata) => void; + addPosition: (position: Types.PacketMetadata) => void; + updateFavorite: (nodeNum: number, isFavorite: boolean) => void; + updateIgnore: (nodeNum: number, isIgnored: boolean) => void; + setNodeNum: (nodeNum: number) => void; + setNodeError: (nodeNum: number, error: NodeErrorType) => void; + clearNodeError: (nodeNum: number) => void; + removeAllNodeErrors: () => void; + + getNodesLength: () => number; + getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; + getNodes: ( + filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, + ) => Protobuf.Mesh.NodeInfo[]; + getMyNode: () => Protobuf.Mesh.NodeInfo; + + getNodeError: (nodeNum: number) => NodeError | undefined; + hasNodeError: (nodeNum: number) => boolean; +} + +export interface nodeDBState { + addNodeDB: (id: number) => NodeDB; + removeNodeDB: (id: number) => void; + getNodeDBs: () => NodeDB[]; + getNodeDB: (id: number) => NodeDB | undefined; +} + +interface PrivateNodeDBState extends nodeDBState { + nodeDBs: Map; +} + +type NodeDBData = { + id: number; + myNodeNum: number | undefined; + nodeMap: Map; + nodeErrors: Map; +}; + +type NodeDBPersisted = { + nodeDBs: Map; +}; + +function nodeDBFactory( + id: number, + get: () => PrivateNodeDBState, + set: typeof useNodeDBStore.setState, + data?: Partial, +): NodeDB { + const nodeMap = data?.nodeMap ?? new Map(); + const nodeErrors = data?.nodeErrors ?? new Map(); + const myNodeNum = data?.myNodeNum; + + return { + id, + myNodeNum, + nodeMap, + nodeErrors, + + addNode: (node) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + nodeDB.nodeMap.set(node.num, node); + }), + ), + + removeNode: (nodeNum) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + nodeDB.nodeMap.delete(nodeNum); + }), + ), + + removeAllNodes: (keepMyNode) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + const newNodeMap = new Map(); + if ( + keepMyNode && + nodeDB.myNodeNum !== undefined && + nodeDB.nodeMap.has(nodeDB.myNodeNum) + ) { + newNodeMap.set( + nodeDB.myNodeNum, + nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? + create(Protobuf.Mesh.NodeInfoSchema), + ); + } + nodeDB.nodeMap = newNodeMap; + }), + ), + + setNodeError: (nodeNum, error) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + nodeDB.nodeErrors.set(nodeNum, { node: nodeNum, error }); + }), + ), + + clearNodeError: (nodeNum) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + nodeDB.nodeErrors.delete(nodeNum); + }), + ), + + removeAllNodeErrors: () => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + nodeDB.nodeErrors = new Map(); + }), + ), + + processPacket: (data) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + const node = nodeDB.nodeMap.get(data.from); + if (node) { + node.lastHeard = data.time; + node.snr = data.snr; + nodeDB.nodeMap.set(data.from, node); + } else { + nodeDB.nodeMap.set( + data.from, + create(Protobuf.Mesh.NodeInfoSchema, { + num: data.from, + lastHeard: data.time, + snr: data.snr, + }), + ); + } + }), + ), + + addUser: (user) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + const current = + nodeDB.nodeMap.get(user.from) ?? + create(Protobuf.Mesh.NodeInfoSchema); + current.user = user.data; + current.num = user.from; + nodeDB.nodeMap.set(user.from, current); + }), + ), + + addPosition: (position) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + const current = + nodeDB.nodeMap.get(position.from) ?? + create(Protobuf.Mesh.NodeInfoSchema); + current.position = position.data; + current.num = position.from; + nodeDB.nodeMap.set(position.from, current); + }), + ), + + setNodeNum: (nodeNum) => + set( + produce((draft) => { + const newDB = draft.nodeDBs.get(id); + if (!newDB) { + throw new Error(`No nodeDB found for id: ${id}`); + } + + newDB.myNodeNum = nodeNum; + + for (const [key, oldDB] of draft.nodeDBs) { + if (key === id) { + continue; + } + if (oldDB.myNodeNum === nodeNum) { + // The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB + // otherwise, discard the old DB completely + if (newDB.nodeMap.size === 0) { + newDB.nodeMap = oldDB.nodeMap; + newDB.nodeErrors = oldDB.nodeErrors; + } else { + console.error( + `NodeDB with id: ${id} already has nodes, not merging with old DB`, + ); + } + + draft.nodeDBs.delete(key); + } + } + }), + ), + + updateFavorite: (nodeNum, isFavorite) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + + const node = nodeDB.nodeMap.get(nodeNum); + if (node) { + node.isFavorite = isFavorite; + } + }), + ), + + updateIgnore: (nodeNum, isIgnored) => + set( + produce((draft) => { + const nodeDB = draft.nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + + const node = nodeDB.nodeMap.get(nodeNum); + if (node) { + node.isIgnored = isIgnored; + } + }), + ), + + getNodesLength: () => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + return nodeDB.nodeMap.size; + }, + + getNode: (nodeNum) => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + return nodeDB.nodeMap.get(nodeNum); + }, + + getNodes: (filter) => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + const all = Array.from(nodeDB.nodeMap.values()).filter( + (n) => n.num !== nodeDB.myNodeNum, + ); + return filter ? all.filter(filter) : all; + }, + + getMyNode: () => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + if (!nodeDB.myNodeNum) { + throw new Error(`No myNodeNum set for nodeDB with id: ${id}`); + } + return ( + nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? + create(Protobuf.Mesh.NodeInfoSchema) + ); + }, + + getNodeError: (nodeNum) => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + return nodeDB.nodeErrors.get(nodeNum); + }, + + hasNodeError: (nodeNum) => { + const nodeDB = get().nodeDBs.get(id); + if (!nodeDB) { + throw new Error(`No nodeDB found (id: ${id})`); + } + return nodeDB.nodeErrors.has(nodeNum); + }, + }; +} + +export const nodeDBInitializer: StateCreator = ( + set, + get, +) => ({ + nodeDBs: new Map(), + + addNodeDB: (id) => { + const existing = get().nodeDBs.get(id); + if (existing) { + return existing; + } + + const nodeDB = nodeDBFactory(id, get, set); + set( + produce((draft) => { + draft.nodeDBs.set(id, nodeDB); + + // If over limit, remove oldest inserted. FIFO + if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) { + const firstKey = draft.nodeDBs.keys().next().value; + if (firstKey !== undefined) { + draft.nodeDBs.delete(firstKey); + } + } + }), + ); + + return nodeDB; + }, + removeNodeDB: (id) => { + set( + produce((draft) => { + draft.nodeDBs.delete(id); + }), + ); + }, + getNodeDBs: () => Array.from(get().nodeDBs.values()), + getNodeDB: (id) => get().nodeDBs.get(id), +}); + +const persistOptions: PersistOptions = { + name: "meshtastic-nodedb-store", + storage: createStorage(), + version: CURRENT_STORE_VERSION, + partialize: (s): NodeDBPersisted => ({ + nodeDBs: new Map( + Array.from(s.nodeDBs.entries()).map(([id, db]) => [ + id, + { + id: db.id, + myNodeNum: db.myNodeNum, + nodeMap: db.nodeMap, + nodeErrors: db.nodeErrors, + }, + ]), + ), + }), + onRehydrateStorage: () => (state) => { + if (!state) { + return; + } + console.debug( + "NodeDBStore: Rehydrating state with ", + state.nodeDBs.size, + " nodeDBs -", + state.nodeDBs, + ); + + useNodeDBStore.setState( + produce((draft) => { + const rebuilt = new Map(); + for (const [id, data] of ( + draft.nodeDBs as unknown as Map + ).entries()) { + if (data.myNodeNum !== undefined) { + // Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate + rebuilt.set( + id, + nodeDBFactory( + id, + useNodeDBStore.getState, + useNodeDBStore.setState, + data, + ), + ); + } + } + draft.nodeDBs = rebuilt; + }), + ); + }, +}; + +// Add persist middleware on the store if the feature flag is enabled +const persistNodes = featureFlags.get("persistNodeDB"); +console.debug( + `NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`, +); + +export const useNodeDBStore = persistNodes + ? createStore( + persist(nodeDBInitializer, persistOptions), + ) + : createStore()(nodeDBInitializer); diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts new file mode 100644 index 00000000..031ee84d --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts @@ -0,0 +1,30 @@ +import { vi } from "vitest"; +import type { NodeDB } from "./index.ts"; + +/** + * You can spread this base mock in your tests and override only the + * properties relevant to a specific test case. + */ +export const mockNodeDBStore: NodeDB = { + id: 0, + myNodeNum: 0, + nodeErrors: new Map(), + nodeMap: new Map(), + + addNode: vi.fn(), + addUser: vi.fn(), + addPosition: vi.fn(), + removeNode: vi.fn(), + processPacket: vi.fn(), + setNodeError: vi.fn(), + clearNodeError: vi.fn(), + getNodeError: vi.fn().mockReturnValue(undefined), + hasNodeError: vi.fn().mockReturnValue(false), + getNodes: vi.fn().mockReturnValue([]), + getNodesLength: vi.fn().mockReturnValue(0), + getNode: vi.fn().mockReturnValue(undefined), + getMyNode: vi.fn(), + updateFavorite: vi.fn(), + updateIgnore: vi.fn(), + setNodeNum: vi.fn(), +}; diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts new file mode 100644 index 00000000..f13e3e9c --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const idbMem = new Map(); +vi.mock("idb-keyval", () => ({ + get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), + set: vi.fn((key: string, val: string) => { + idbMem.set(key, val); + return Promise.resolve(); + }), + del: vi.fn((k: string) => { + idbMem.delete(k); + return Promise.resolve(); + }), +})); + +// import a fresh copy of the store module (because the store is created at import time) +async function freshStore() { + vi.resetModules(); + const mod = await import("../nodeDBStore"); + return mod; +} + +vi.mock("@core/services/featureFlags", () => { + return { + featureFlags: { + get: vi.fn((key: string) => { + if (key === "persistNodeDB") return true; + return false; + }), + }, + }; +}); + +function makeNode(num: number, extras: Record = {}) { + return { num, ...extras } as any; +} + +describe("NodeDB store", () => { + beforeEach(() => { + idbMem.clear(); + vi.clearAllMocks(); + }); + + it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => { + const { useNodeDBStore } = await freshStore(); + + const db1 = useNodeDBStore.getState().addNodeDB(123); + const db2 = useNodeDBStore.getState().addNodeDB(123); + expect(db1).toBe(db2); + + const got = useNodeDBStore.getState().getNodeDB(123); + expect(got).toBe(db1); + + expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1); + }); + + it("addNode, getNode(s), getNodesLength, removeNode", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.addNode(makeNode(10)); + db.addNode(makeNode(11)); + expect(db.getNodesLength()).toBe(2); + expect(db.getNode(10)?.num).toBe(10); + + const all = db.getNodes(); + expect(all.map(n => n.num).sort()).toEqual([10, 11]); + + db.removeNode(10); + expect(db.getNodesLength()).toBe(1); + expect(db.getNode(10)).toBeUndefined(); + }); + + it("processPacket creates or updates a node", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.processPacket({ from: 50, time: 1111, snr: 7 } as any); + expect(db.getNode(50)).toBeTruthy(); + expect(db.getNode(50)?.lastHeard).toBe(1111); + expect(db.getNode(50)?.snr).toBe(7); + + db.processPacket({ from: 50, time: 2222, snr: 9 } as any); + expect(db.getNode(50)?.lastHeard).toBe(2222); + expect(db.getNode(50)?.snr).toBe(9); + }); + + it("addUser and addPosition updates existing or creates new nodes", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + // addUser creates node if missing + db.addUser({ from: 77, data: { id: "u" } } as any); + expect(db.getNode(77)?.user).toEqual({ id: "u" }); + + // addPosition updates same node + db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any); + expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 }); + expect(db.getNode(77)?.num).toBe(77); + }); + + it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + + db.setNodeError(10, "BadFoo" as any); + expect(db.hasNodeError(10)).toBe(true); + expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" }); + + db.clearNodeError(10); + expect(db.hasNodeError(10)).toBe(false); + expect(db.getNodeError(10)).toBeUndefined(); + }); + + it("getMyNode throws before setNodeNum; works after", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + db.addNode(makeNode(123)); + + expect(() => db.getMyNode()).toThrow(); + db.setNodeNum(123); + + const me = db.getMyNode(); + expect(me.num).toBe(123); + }); + + it("setNodeNum merges with existing DB with same myNodeNum", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + + const oldDB = st.addNodeDB(10); + oldDB.setNodeNum(999); + oldDB.addNode(makeNode(200)); + oldDB.setNodeError(200, "ERROR" as any); + + const newDB = st.addNodeDB(11); + // newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old + newDB.setNodeNum(999); + + expect(st.getNodeDB(10)).toBeUndefined(); + expect(st.getNodeDB(11)).toBeDefined(); + expect(newDB.getNode(200)).toBeTruthy(); + expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" }); + }); + + it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + const oldDB = st.addNodeDB(10); + oldDB.setNodeNum(999); + oldDB.addNode(makeNode(200)); // old has data + + const newDB = st.addNodeDB(11); + newDB.addNode(makeNode(300)); // new has data -> no merge + newDB.setNodeNum(999); + + expect(st.getNodeDB(10)).toBeUndefined(); // old removed + // new kept its own nodes; did not copy old's + expect(newDB.getNode(300)).toBeTruthy(); + expect(newDB.getNode(200)).toBeUndefined(); + }); + + it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.addNodeDB(123); + db.setNodeNum(321); + db.addNode(makeNode(50)); + db.setNodeError(50, "ERROR" as any); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.getNodeDB(123)!; + + // methods should work after rehydrate + expect(db.getNode(50)?.num).toBe(50); + expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" }); + db.addNode(makeNode(51)); + expect(db.getNode(51)).toBeTruthy(); + } + }); + + it("getNodes applies filter and excludes myNodeNum", async () => { + const { useNodeDBStore } = await freshStore(); + const db = useNodeDBStore.getState().addNodeDB(1); + db.setNodeNum(11); + db.addNode(makeNode(10)); + db.addNode(makeNode(11)); + db.addNode(makeNode(12)); + + const all = db.getNodes(); + expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11) + + const filtered = db.getNodes((n) => n.num > 10); + expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11 + }); + + it("when exceeding cap, evicts earliest inserted, not the newly added", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + for (let i = 1; i <= 10; i++) st.addNodeDB(i); + st.addNodeDB(11); + expect(st.getNodeDB(1)).toBeUndefined(); + expect(st.getNodeDB(11)).toBeDefined(); + }); + + it("removeNodeDB persists removal across reload", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + st.addNodeDB(99); + expect(st.getNodeDB(99)).toBeDefined(); + st.removeNodeDB(99); + expect(st.getNodeDB(99)).toBeUndefined(); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + expect(st.getNodeDB(99)).toBeUndefined(); // still gone + } + }); + + it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => { + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + + const orphan = st.addNodeDB(500); // no setNodeNum + orphan.addNode(makeNode(1)); + + const good = st.addNodeDB(501); + good.setNodeNum(42); + good.addNode(makeNode(2)); + } + { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped + expect(st.getNodeDB(501)).toBeDefined(); // kept + expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy(); + } + }); + + it("methods throw after their DB is removed from the map", async () => { + const { useNodeDBStore } = await freshStore(); + const st = useNodeDBStore.getState(); + const db = st.addNodeDB(800); + + st.removeNodeDB(800); + + expect(() => db.getNodesLength()).toThrow(/No nodeDB found/); + expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/); + }); +}); diff --git a/packages/web/src/core/stores/nodeDBStore/types.ts b/packages/web/src/core/stores/nodeDBStore/types.ts new file mode 100644 index 00000000..105ddbf0 --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/types.ts @@ -0,0 +1,16 @@ +import type { Protobuf } from "@meshtastic/core"; + +type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI"; + +type NodeError = { + node: number; + error: NodeErrorType; +}; + +type ProcessPacketParams = { + from: number; + snr: number; + time: number; +}; + +export type { NodeError, ProcessPacketParams, NodeErrorType }; diff --git a/packages/web/src/core/stores/utils/indexDB.test.ts b/packages/web/src/core/stores/utils/indexDB.test.ts new file mode 100644 index 00000000..14abe329 --- /dev/null +++ b/packages/web/src/core/stores/utils/indexDB.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as idb from "idb-keyval"; +import { createStorage } from "./indexDB"; + +type PersistStorage = ReturnType>; + +describe("indexDB.ts persistence (steps 1–5)", () => { + let store: PersistStorage; + + beforeEach(() => { + vi.restoreAllMocks(); + store = createStorage(); + }); + + async function roundTrip(obj: any) { + const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); + const getSpy = vi.spyOn(idb, "get"); + await store.setItem("rt", obj); + const storedStr = (setSpy.mock.calls[0] as any[])[1] as string; + getSpy.mockResolvedValue(storedStr); + return await store.getItem("rt"); + } + + // Basic methods + it("getItem returns null when idb-keyval.get yields undefined", async () => { + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); + const res = await store.getItem("missing-key"); + expect(getSpy).toHaveBeenCalledWith("missing-key"); + expect(res).toBeNull(); + }); + + it("setItem writes a string via idb-keyval.set", async () => { + const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); + const payload = { state: { a: 1 }, version: 0 }; + await store.setItem("k1", payload); + expect(setSpy).toHaveBeenCalledTimes(1); + const [, value] = setSpy.mock.calls[0]!; + expect(typeof value).toBe("string"); + // sanity: it should be JSON + expect(() => JSON.parse(value as string)).not.toThrow(); + }); + + it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => { + const delSpy = vi.spyOn(idb, "del").mockResolvedValue(); + await store.removeItem("k2"); + expect(delSpy).toHaveBeenCalledWith("k2"); + + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); + const res = await store.getItem("k2"); + expect(getSpy).toHaveBeenCalledWith("k2"); + expect(res).toBeNull(); + }); + + // Map + it("round-trips an empty Map", async () => { + const out = await roundTrip({ state: { m: new Map() }, version: 0 }); + expect(out?.state.m instanceof Map).toBe(true); + expect(out?.state.m.size).toBe(0); + }); + + it("round-trips a Map and preserves numeric key semantics", async () => { + const m = new Map([[1, "a"]]); + const out = await roundTrip({ state: { m }, version: 0 }); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + expect(m2.get(1)).toBe("a"); + }); + + it("round-trips nested Map inside arrays/objects", async () => { + const payload = { + state: { + list: [new Map([[2, 3]])], + obj: { inner: new Map([["k", 7]]) }, + }, + version: 0, + }; + const out = await roundTrip(payload); + + expect(out!.state.list[0] instanceof Map).toBe(true); + expect((out!.state.list[0] as Map).get(2)).toBe(3); + expect(out!.state.obj.inner instanceof Map).toBe(true); + expect((out!.state.obj.inner as Map).get("k")).toBe(7); + }); + + // Uint8Array + it("round-trips a Uint8Array (simple)", async () => { + const u8 = new Uint8Array([1, 2, 255]); + const out = await roundTrip({ state: { u8 }, version: 0 }); + const u2 = out!.state.u8 as Uint8Array; + expect(u2 instanceof Uint8Array).toBe(true); + expect(Array.from(u2)).toEqual([1, 2, 255]); + }); + + it("round-trips a Uint8Array view with non-zero byteOffset", async () => { + const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer; + const view = new Uint8Array(buf, 2, 4); // [8,7,6,5] + const out = await roundTrip({ state: { view }, version: 0 }); + const v2 = out!.state.view as Uint8Array; + expect(v2 instanceof Uint8Array).toBe(true); + expect(Array.from(v2)).toEqual([8, 7, 6, 5]); + // ensure it's a standalone buffer now, without offset + expect(v2.byteOffset).toBe(0); + }); + + // Mixed & nested structures + it("round-trips Map values containing objects with nested Uint8Array", async () => { + const inner = { key: new Uint8Array([7, 8]) }; + const m = new Map([[42, inner]]); + const out = await roundTrip({ state: { m }, version: 0 }); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + + const got = m2.get(42)!; + expect(got.key instanceof Uint8Array).toBe(true); + expect(Array.from(got.key)).toEqual([7, 8]); + }); + + it("round-trips deep nesting (array → object → map → u8)", async () => { + const payload = { + state: { + arr: [ + { + obj: { + m: new Map([["k", new Uint8Array([9])]]), + }, + }, + ], + }, + version: 0, + }; + const out = await roundTrip(payload); + const m2 = out!.state.arr[0].obj.m as Map; + expect(m2 instanceof Map).toBe(true); + const u = m2.get("k")!; + expect(u instanceof Uint8Array).toBe(true); + expect(Array.from(u)).toEqual([9]); + }); + + it("does not alter plain objects/arrays", async () => { + const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 }; + const out = await roundTrip(payload); + expect(out).toEqual(payload); + }); + + it("revives envelope-looking objects", async () => { + // Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope. + const forged = JSON.stringify({ + state: { m: { __datatype: "Map", value: [[1, "x"]] } }, + version: 0, + }); + const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged); + const out = await store.getItem("forged"); + expect(getSpy).toHaveBeenCalled(); + const m2 = out!.state.m as Map; + expect(m2 instanceof Map).toBe(true); + expect(m2.get(1)).toBe("x"); + }); +}); \ No newline at end of file diff --git a/packages/web/src/core/stores/utils/indexDB.ts b/packages/web/src/core/stores/utils/indexDB.ts index 26d219d3..216f52ab 100644 --- a/packages/web/src/core/stores/utils/indexDB.ts +++ b/packages/web/src/core/stores/utils/indexDB.ts @@ -1,7 +1,3 @@ -import type { - ChannelId, - MessageLogMap, -} from "@core/stores/messageStore/types.ts"; import { del, get, set } from "idb-keyval"; import type { PersistStorage, @@ -9,14 +5,6 @@ import type { StorageValue, } from "zustand/middleware"; -type PersistedMessageState = { - messages: { - direct: Map; - broadcast: Map; - }; - nodeNum: number; -}; - export const zustandIndexDBStorage: StateStorage = { getItem: async (name: string): Promise => { return (await get(name)) || null; @@ -29,73 +17,118 @@ export const zustandIndexDBStorage: StateStorage = { }, }; -type SerializedMap = { - __dataType: "Map"; - value: Array<[K, V]>; -}; +type AnyRecord = Record; -type JsonReplacer = (key: string, value: unknown) => unknown; -const replacer: JsonReplacer = (_, value) => { - if (value instanceof Map) { - const map = value as Map; - const serialized: SerializedMap = { - __dataType: "Map", - value: Array.from(map.entries()), - }; - return serialized; - } - return value; +type Envelope = { + __datatype: Tag; + value: V; }; -type JsonReviver = (key: string, value: unknown) => unknown; -function isSerializedMap(value: unknown): value is SerializedMap { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false; - } - const potentialMap = value as Partial; - return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value); +interface Handler { + tag: Tag; + test(val: unknown): val is T; + serialize(val: T): Encoded; + revive(encoded: Encoded): T; } -const reviver: JsonReviver = (_, value) => { - if (isSerializedMap(value)) { - return new Map(value.value); - } - return value; + +function isObject(x: unknown): x is AnyRecord { + return typeof x === "object" && x !== null; +} +function isEnvelope(x: unknown): x is Envelope { + return isObject(x) && typeof x.__datatype === "string" && "value" in x; +} + +// Map handler +type SerializedMap = Array<[K, V]>; +const mapHandler: Handler, "Map", SerializedMap> = { + tag: "Map", + test: (val): val is Map => val instanceof Map, + serialize: (map) => Array.from(map.entries()), + revive: (pairs) => new Map(pairs), }; -export const storageWithMapSupport: PersistStorage = { - getItem: async ( - name, - ): Promise | null> => { - const str = await zustandIndexDBStorage.getItem(name); - if (!str) { - return null; - } - try { - const parsed = JSON.parse( - str, - reviver, - ) as StorageValue; - return parsed; - } catch (error) { - console.error(`Error parsing persisted state (${name}):`, error); - return null; +// Uint8Array handler +const uint8ArrayHandler: Handler = { + tag: "Uint8Array", + test: (val): val is Uint8Array => val instanceof Uint8Array, + serialize: (uint8array) => Array.from(uint8array), + revive: (arr) => new Uint8Array(arr), +}; + +const defaultHandlers = [mapHandler, uint8ArrayHandler] as const; + +function makeJson>( + handlers: readonly H[], +) { + const byTag = new Map(); + for (const handler of handlers) { + byTag.set(handler.tag, handler); + } + + const replacer = (_: string, value: unknown): unknown => { + for (const handler of handlers) { + if (handler.test(value)) { + const encoded = handler.serialize(value as never); + const envelope: Envelope = { + __datatype: handler.tag, + value: encoded, + }; + return envelope; + } } - }, - setItem: async ( - name, - newValue: StorageValue, - ): Promise => { - try { - const str = JSON.stringify(newValue, replacer); - await zustandIndexDBStorage.setItem(name, str); - } catch (error) { - console.error( - `Error stringifying or setting persisted state (${name}):`, - error, - ); + return value; + }; + + const reviver = (_: string, value: unknown): unknown => { + if (isEnvelope(value)) { + const handler = byTag.get(value.__datatype); + if (handler) { + return handler.revive( + (value as Envelope).value as never, + ); + } } - }, - removeItem: async (name): Promise => { - await zustandIndexDBStorage.removeItem(name); - }, -}; + return value; + }; + + return { replacer, reviver }; +} + +export function createStorage< + T, + H extends Handler = never, +>(extraHandlers: readonly H[] = [] as const): PersistStorage { + const { replacer, reviver } = makeJson([ + ...defaultHandlers, + ...extraHandlers, + ]); + return { + getItem: async (name): Promise | null> => { + const str = await zustandIndexDBStorage.getItem(name); + if (!str) { + return null; + } + try { + const parsed = JSON.parse(str, reviver) as StorageValue; + return parsed; + } catch (error) { + console.error(`Error parsing persisted state (${name}):`, error); + return null; + } + }, + setItem: async (name, newValue: StorageValue): Promise => { + try { + const str = JSON.stringify(newValue, replacer); + await zustandIndexDBStorage.setItem(name, str); + } catch (error) { + console.error( + `Error stringifying or setting persisted state (${name}):`, + error, + ); + } + }, + removeItem: async (name): Promise => { + await zustandIndexDBStorage.removeItem(name); + }, + }; +} diff --git a/packages/web/src/core/stores/utils/useDeviceContext.ts b/packages/web/src/core/stores/utils/useDeviceContext.ts new file mode 100644 index 00000000..8a608bf9 --- /dev/null +++ b/packages/web/src/core/stores/utils/useDeviceContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; + +export type DeviceContext = { + deviceId: number; // Unique identifier for the device, not nodeNum +}; + +export const CurrentDeviceContext = createContext( + undefined, +); + +export function useDeviceContext(): DeviceContext { + const ctx = useContext(CurrentDeviceContext); + if (!ctx) { + throw new Error( + "useDeviceContext must be used within CurrentDeviceContext provider", + ); + } + return ctx; +} diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 3415cc25..4c1791f0 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -1,12 +1,19 @@ import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts"; import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; -import { type Device, type MessageStore, MessageType } from "@core/stores"; +import { + type Device, + type MessageStore, + MessageType, + type NodeDB, +} from "@core/stores"; import { type MeshDevice, Protobuf } from "@meshtastic/core"; +import { fromByteArray } from "base64-js"; export const subscribeAll = ( device: Device, connection: MeshDevice, messageStore: MessageStore, + nodeDB: NodeDB, ) => { let myNodeNum = 0; @@ -52,20 +59,40 @@ export const subscribeAll = ( connection.events.onMyNodeInfo.subscribe((nodeInfo) => { device.setHardware(nodeInfo); messageStore.setNodeNum(nodeInfo.myNodeNum); + nodeDB.setNodeNum(nodeInfo.myNodeNum); myNodeNum = nodeInfo.myNodeNum; }); connection.events.onUserPacket.subscribe((user) => { - device.addUser(user); + nodeDB.addUser(user); }); connection.events.onPositionPacket.subscribe((position) => { - device.addPosition(position); + nodeDB.addPosition(position); }); connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { const nodeWithUser = ensureDefaultUser(nodeInfo); - device.addNodeInfo(nodeWithUser); + + if (nodeWithUser.num !== myNodeNum && nodeDB.getNode(nodeWithUser.num)) { + const oldPublicKey = fromByteArray( + nodeDB.getNode(nodeWithUser.num)?.user?.publicKey ?? new Uint8Array(), + ); + const newPublicKey = fromByteArray( + nodeWithUser.user?.publicKey ?? new Uint8Array(), + ); + + if (oldPublicKey !== newPublicKey) { + console.warn( + `Node ${nodeWithUser.user?.longName} (${nodeWithUser.num}) has a different public key than expected: Expected ${oldPublicKey} but got ${newPublicKey}`, + ); + nodeDB.setNodeError(nodeWithUser.num, "MISMATCH_PKI"); + + // TODO: Handle this error case properly (refactor PKI dialog?) + } + } + + nodeDB.addNode(nodeWithUser); }); connection.events.onChannelPacket.subscribe((channel) => { @@ -106,7 +133,7 @@ export const subscribeAll = ( }); connection.events.onMeshPacket.subscribe((meshPacket) => { - device.processPacket({ + nodeDB.processPacket({ from: meshPacket.from, snr: meshPacket.rxSnr, time: meshPacket.rxTime, @@ -128,17 +155,17 @@ export const subscribeAll = ( break; case Protobuf.Mesh.Routing_Error.NO_CHANNEL: console.error(`Routing Error: ${routingPacket.data.variant.value}`); - device.setNodeError( + nodeDB.setNodeError( routingPacket.from, - Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value], + routingPacket?.data?.variant?.value, ); device.setDialogOpen("refreshKeys", true); break; case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: console.error(`Routing Error: ${routingPacket.data.variant.value}`); - device.setNodeError( + nodeDB.setNodeError( routingPacket.from, - Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value], + routingPacket?.data?.variant?.value, ); device.setDialogOpen("refreshKeys", true); break; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 40cc7da1..567e2e08 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -1,5 +1,8 @@ import React from "react"; import "@app/index.css"; + +// Import feature flags and dev overrides +import "@core/services/dev-overrides.ts"; import { enableMapSet } from "immer"; import "maplibre-gl/dist/maplibre-gl.css"; import { Suspense } from "react"; diff --git a/packages/web/src/pages/Dashboard/index.tsx b/packages/web/src/pages/Dashboard/index.tsx index d7903efb..783b8c39 100644 --- a/packages/web/src/pages/Dashboard/index.tsx +++ b/packages/web/src/pages/Dashboard/index.tsx @@ -3,7 +3,7 @@ import { Button } from "@components/UI/Button.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { useAppStore, useDeviceStore } from "@core/stores"; +import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores"; import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +12,7 @@ export const Dashboard = () => { const { t } = useTranslation("dashboard"); const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { getDevices } = useDeviceStore(); + const { getNodeDB } = useNodeDBStore(); const devices = useMemo(() => getDevices(), [getDevices]); @@ -31,6 +32,11 @@ export const Dashboard = () => { {devices.length ? (
    {devices.map((device) => { + const nodeDB = getNodeDB(device.id); + if (!nodeDB) { + return; + } + return (
diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx index 39d23dc2..b7b2217a 100644 --- a/packages/web/src/pages/Map/index.tsx +++ b/packages/web/src/pages/Map/index.tsx @@ -6,7 +6,7 @@ import { import { BaseMap } from "@components/Map.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; -import { useDevice } from "@core/stores"; +import { useDevice, useNodeDB } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; import type { Protobuf } from "@meshtastic/core"; import { bbox, lineString } from "@turf/turf"; @@ -30,7 +30,8 @@ const convertToLatLng = (position?: { }); const MapPage = () => { - const { getNodes, waypoints, hasNodeError } = useDevice(); + const { waypoints } = useDevice(); + const { getNodes, hasNodeError } = useNodeDB(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); const { default: map } = useMap(); @@ -79,7 +80,7 @@ const MapPage = () => { return; } - if (validNodes.length === 1) { + if (validNodes.length === 1 && validNodes[0]) { map.easeTo({ zoom: map.getZoom(), center: [ diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index bcbac8c4..37d9ed1d 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -13,6 +13,7 @@ import { MessageType, useDevice, useMessageStore, + useNodeDB, useSidebar, } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; @@ -42,15 +43,9 @@ function SelectMessageChat() { } export const MessagesPage = () => { - const { - channels, - getNodes, - getNode, - hasNodeError, - getUnreadCount, - resetUnread, - connection, - } = useDevice(); + const { channels, getUnreadCount, resetUnread, connection } = useDevice(); + const { getNodes, getNode, hasNodeError } = useNodeDB(); + const { getMyNodeNum, getMessages, setMessageState } = useMessageStore(); const { type, chatId } = useParams({ from: messagesWithParamsRoute.id }); diff --git a/packages/web/src/pages/Nodes/index.tsx b/packages/web/src/pages/Nodes/index.tsx index 6e06b024..7f3448ad 100644 --- a/packages/web/src/pages/Nodes/index.tsx +++ b/packages/web/src/pages/Nodes/index.tsx @@ -17,7 +17,7 @@ import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { Input } from "@components/UI/Input.tsx"; import useLang from "@core/hooks/useLang.ts"; -import { useAppStore, useDevice } from "@core/stores"; +import { useAppStore, useDevice, useNodeDB } from "@core/stores"; import { Protobuf, type Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { LockIcon, LockOpenIcon } from "lucide-react"; @@ -40,8 +40,8 @@ export interface DeleteNoteDialogProps { const NodesPage = (): JSX.Element => { const { t } = useTranslation("nodes"); const { currentLanguage } = useLang(); - const { getNodes, hardware, connection, hasNodeError, setDialogOpen } = - useDevice(); + const { hardware, connection, setDialogOpen } = useDevice(); + const { getNodes, hasNodeError } = useNodeDB(); const { setNodeNumDetails } = useAppStore(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index e793d36b..c561d19c 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -1,23 +1,27 @@ import path from "node:path"; -import process from "node:process"; +import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { enableMapSet } from "immer"; import { defineProject } from "vitest/config"; enableMapSet(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const pkgRoot = __dirname; +const srcDir = path.resolve(pkgRoot, "src"); +const publicDir = path.resolve(pkgRoot, "public"); + export default defineProject({ plugins: [react()], resolve: { alias: { - "@app": path.resolve(process.cwd(), "./packages/web/src"), - "@public": path.resolve(process.cwd(), "./packages/web/public"), - "@core": path.resolve(process.cwd(), "./packages/web/src/core"), - "@pages": path.resolve(process.cwd(), "./packages/web/src/pages"), - "@components": path.resolve( - process.cwd(), - "./packages/web/src/components", - ), - "@layouts": path.resolve(process.cwd(), "./packages/web/src/layouts"), + "@app": srcDir, + "@public": publicDir, + "@core": path.resolve(srcDir, "core"), + "@pages": path.resolve(srcDir, "pages"), + "@components": path.resolve(srcDir, "components"), + "@layouts": path.resolve(srcDir, "layouts"), }, }, test: { @@ -26,8 +30,7 @@ export default defineProject({ mockReset: true, clearMocks: true, restoreMocks: true, - root: path.resolve(process.cwd(), "./packages/web/src"), - include: ["**/*.{test,spec}.{ts,tsx}"], - setupFiles: ["./src/tests/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + setupFiles: [path.resolve(srcDir, "tests/setup.ts")], }, }); diff --git a/tests/utils/transportContract.ts b/tests/utils/transportContract.ts new file mode 100644 index 00000000..bafdb92f --- /dev/null +++ b/tests/utils/transportContract.ts @@ -0,0 +1,122 @@ +import { Types } from "@meshtastic/core"; +import { describe, expect, it } from "vitest"; + +export interface TransportContract { + name: string; + create: () => Promise; + setup?: () => void | Promise; + teardown?: () => void | Promise; + pushIncoming?: (bytes: Uint8Array) => void | Promise; + assertLastWritten?: (bytes: Uint8Array) => void; + triggerDisconnect?: () => void | Promise; +} + +async function readUntilType( + reader: ReadableStreamDefaultReader, + expectedType: Types.DeviceOutput["type"], + maxReads = 20, +): Promise { + for (let i = 0; i < maxReads; i++) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (value && value.type === expectedType) { + return value; + } + } + throw new Error( + `Did not receive a '${expectedType}' event within ${maxReads} reads`, + ); +} + +export function runTransportContract(contract: TransportContract) { + describe(contract.name, () => { + it("reads packets from fromDevice", async () => { + await contract.setup?.(); + const transport = await contract.create(); + + const reader = transport.fromDevice.getReader(); + const sampleBytes = new Uint8Array([0x01, 0x02, 0x03]); + + await contract.pushIncoming?.(sampleBytes); + + const packetEvent = await readUntilType(reader, "packet"); + expect("data" in packetEvent ? packetEvent.data : undefined).toEqual( + sampleBytes, + ); + + reader.releaseLock(); + await contract.teardown?.(); + }); + + it("writes bytes to toDevice", async () => { + await contract.setup?.(); + const transport = await contract.create(); + + const writer = transport.toDevice.getWriter(); + const outgoingBytes = new Uint8Array([0xaa, 0xbb]); + await writer.write(outgoingBytes); + await writer.close(); + + contract.assertLastWritten?.(outgoingBytes); + await contract.teardown?.(); + }); + + it("disconnect() emits DeviceDisconnected('user')", async () => { + await contract.setup?.(); + const transport = await contract.create(); + + const reader = transport.fromDevice.getReader(); + + // Trigger user disconnect + await transport.disconnect(); + + // Read a few events and assert we eventually see the user disconnect. + let sawUser = false; + for (let i = 0; i < 10; i++) { + const { value } = await reader.read(); + if ( + value && + value.type === "status" && + value.data.status === Types.DeviceStatusEnum.DeviceDisconnected && + value.data.reason === "user" + ) { + sawUser = true; + break; + } + } + expect(sawUser).toBe(true); + + reader.releaseLock(); + await contract.teardown?.(); + }); + + it("emits DeviceDisconnected when the underlying link drops", async () => { + await contract.setup?.(); + const transport = await contract.create(); + + const reader = transport.fromDevice.getReader(); + + await contract.triggerDisconnect?.(); + + // As above, read a few events and assert we eventually see "disconnected" + let sawDrop = false; + for (let i = 0; i < 10; i++) { + const { value } = await reader.read(); + if ( + value && + value.type === "status" && + value.data.status === Types.DeviceStatusEnum.DeviceDisconnected + ) { + sawDrop = true; + break; + } + } + expect(sawDrop).toBe(true); + + reader.releaseLock(); + await contract.teardown?.(); + }); + }); +}