Browse Source

Merge branch 'main' into new-channel-config

pull/807/head
philon- 11 months ago
parent
commit
867f8f9b61
  1. 1
      .vscode/settings.json
  2. 8
      packages/core/src/types.ts
  3. 13
      packages/core/src/utils/transform/decodePacket.ts
  4. 229
      packages/transport-http/src/transport.test.ts
  5. 226
      packages/transport-http/src/transport.ts
  6. 187
      packages/transport-node-serial/src/transport.test.ts
  7. 131
      packages/transport-node-serial/src/transport.ts
  8. 186
      packages/transport-node/src/transport.test.ts
  9. 135
      packages/transport-node/src/transport.ts
  10. 166
      packages/transport-web-bluetooth/src/transport.test.ts
  11. 212
      packages/transport-web-bluetooth/src/transport.ts
  12. 225
      packages/transport-web-serial/src/transport.test.ts
  13. 189
      packages/transport-web-serial/src/transport.ts
  14. 4
      packages/web/public/i18n/locales/bg-BG/commandPalette.json
  15. 5
      packages/web/public/i18n/locales/bg-BG/common.json
  16. 33
      packages/web/public/i18n/locales/bg-BG/dialog.json
  17. 4
      packages/web/public/i18n/locales/cs-CZ/commandPalette.json
  18. 5
      packages/web/public/i18n/locales/cs-CZ/common.json
  19. 33
      packages/web/public/i18n/locales/cs-CZ/dialog.json
  20. 4
      packages/web/public/i18n/locales/de-DE/commandPalette.json
  21. 5
      packages/web/public/i18n/locales/de-DE/common.json
  22. 33
      packages/web/public/i18n/locales/de-DE/dialog.json
  23. 4
      packages/web/public/i18n/locales/es-ES/commandPalette.json
  24. 5
      packages/web/public/i18n/locales/es-ES/common.json
  25. 33
      packages/web/public/i18n/locales/es-ES/dialog.json
  26. 4
      packages/web/public/i18n/locales/fi-FI/commandPalette.json
  27. 5
      packages/web/public/i18n/locales/fi-FI/common.json
  28. 33
      packages/web/public/i18n/locales/fi-FI/dialog.json
  29. 4
      packages/web/public/i18n/locales/fr-FR/commandPalette.json
  30. 5
      packages/web/public/i18n/locales/fr-FR/common.json
  31. 55
      packages/web/public/i18n/locales/fr-FR/dialog.json
  32. 4
      packages/web/public/i18n/locales/it-IT/commandPalette.json
  33. 5
      packages/web/public/i18n/locales/it-IT/common.json
  34. 33
      packages/web/public/i18n/locales/it-IT/dialog.json
  35. 4
      packages/web/public/i18n/locales/ja-JP/commandPalette.json
  36. 5
      packages/web/public/i18n/locales/ja-JP/common.json
  37. 33
      packages/web/public/i18n/locales/ja-JP/dialog.json
  38. 4
      packages/web/public/i18n/locales/ko-KR/commandPalette.json
  39. 5
      packages/web/public/i18n/locales/ko-KR/common.json
  40. 33
      packages/web/public/i18n/locales/ko-KR/dialog.json
  41. 4
      packages/web/public/i18n/locales/nl-NL/commandPalette.json
  42. 5
      packages/web/public/i18n/locales/nl-NL/common.json
  43. 33
      packages/web/public/i18n/locales/nl-NL/dialog.json
  44. 4
      packages/web/public/i18n/locales/pl-PL/commandPalette.json
  45. 5
      packages/web/public/i18n/locales/pl-PL/common.json
  46. 33
      packages/web/public/i18n/locales/pl-PL/dialog.json
  47. 4
      packages/web/public/i18n/locales/pt-BR/commandPalette.json
  48. 5
      packages/web/public/i18n/locales/pt-BR/common.json
  49. 33
      packages/web/public/i18n/locales/pt-BR/dialog.json
  50. 4
      packages/web/public/i18n/locales/pt-PT/commandPalette.json
  51. 5
      packages/web/public/i18n/locales/pt-PT/common.json
  52. 33
      packages/web/public/i18n/locales/pt-PT/dialog.json
  53. 4
      packages/web/public/i18n/locales/sv-SE/commandPalette.json
  54. 5
      packages/web/public/i18n/locales/sv-SE/common.json
  55. 33
      packages/web/public/i18n/locales/sv-SE/dialog.json
  56. 4
      packages/web/public/i18n/locales/tr-TR/commandPalette.json
  57. 5
      packages/web/public/i18n/locales/tr-TR/common.json
  58. 33
      packages/web/public/i18n/locales/tr-TR/dialog.json
  59. 4
      packages/web/public/i18n/locales/uk-UA/commandPalette.json
  60. 5
      packages/web/public/i18n/locales/uk-UA/common.json
  61. 33
      packages/web/public/i18n/locales/uk-UA/dialog.json
  62. 4
      packages/web/public/i18n/locales/zh-CN/commandPalette.json
  63. 5
      packages/web/public/i18n/locales/zh-CN/common.json
  64. 33
      packages/web/public/i18n/locales/zh-CN/dialog.json
  65. 10
      packages/web/src/App.tsx
  66. 10
      packages/web/src/DeviceWrapper.tsx
  67. 14
      packages/web/src/components/CommandPalette/index.tsx
  68. 5
      packages/web/src/components/Dialog/DeviceNameDialog.tsx
  69. 4
      packages/web/src/components/Dialog/LocationResponseDialog.tsx
  70. 5
      packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  71. 7
      packages/web/src/components/Dialog/PKIBackupDialog.tsx
  72. 58
      packages/web/src/components/Dialog/RebootDialog.test.tsx
  73. 11
      packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  74. 5
      packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  75. 6
      packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  76. 5
      packages/web/src/components/Dialog/RemoveNodeDialog.tsx
  77. 4
      packages/web/src/components/Dialog/TracerouteResponseDialog.tsx
  78. 19
      packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  79. 13
      packages/web/src/components/PageComponents/Connect/BLE.tsx
  80. 5
      packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
  81. 12
      packages/web/src/components/PageComponents/Connect/HTTP.tsx
  82. 13
      packages/web/src/components/PageComponents/Connect/Serial.tsx
  83. 10
      packages/web/src/components/PageComponents/Messages/MessageItem.tsx
  84. 9
      packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
  85. 4
      packages/web/src/components/PageComponents/Messages/TraceRoute.tsx
  86. 18
      packages/web/src/components/Sidebar.tsx
  87. 9
      packages/web/src/core/hooks/useFavoriteNode.test.ts
  88. 19
      packages/web/src/core/hooks/useFavoriteNode.ts
  89. 11
      packages/web/src/core/hooks/useIgnoreNode.test.ts
  90. 22
      packages/web/src/core/hooks/useIgnoreNode.ts
  91. 6
      packages/web/src/core/stores/appStore/index.ts
  92. 17
      packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
  93. 251
      packages/web/src/core/stores/deviceStore/index.ts
  94. 37
      packages/web/src/core/stores/index.ts
  95. 440
      packages/web/src/core/stores/nodeDBStore/index.ts
  96. 30
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
  97. 258
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts
  98. 16
      packages/web/src/core/stores/nodeDBStore/types.ts
  99. 158
      packages/web/src/core/stores/utils/indexDB.test.ts
  100. 181
      packages/web/src/core/stores/utils/indexDB.ts

1
.vscode/settings.json

@ -6,4 +6,5 @@
"search.exclude": {
"**/i18n/locales/*-*/**": true,
},
"vitest.workspaceConfig": "vitest.config.ts",
}

8
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<Uint8Array>;
@ -101,6 +106,7 @@ export enum Emitter {
RemoveNodeByNum = 32,
SetCannedMessages = 33,
Disconnect = 34,
ConnectionStatus = 35,
}
export interface LogEvent {

13
packages/core/src/utils/transform/decodePacket.ts

@ -7,6 +7,19 @@ export const decodePacket = (device: MeshDevice) =>
new WritableStream<DeviceOutput>({
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;
}

229
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<Response> {
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<typeof stubFetch> }).__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<typeof stubFetch> }).__http.pushIncoming(bytes);
await tickNextTimer();
},
assertLastWritten: (bytes) => {
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.assertLastWritten(bytes);
},
triggerDisconnect: async () => {
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.forceReadErrorOnce();
await tickNextTimer();
},
});
});
describe("TransportHTTP (extras)", () => {
let httpStub: ReturnType<typeof stubFetch> | undefined;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
httpStub?.cleanup();
httpStub = undefined;
});
async function createTransport(): Promise<TransportHTTP> {
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();
});
});

226
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<Uint8Array>;
private _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private url: string;
private receiveBatchRequests: boolean;
private fetchInterval: number;
private fetching: boolean;
private interval: ReturnType<typeof setInterval> | 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<Uint8Array>({
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<Types.DeviceOutput>;
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
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<Types.DeviceOutput>,
): Promise<void> {
/** Poll `/api/v1/fromradio` and enqueue incoming packets. */
private async readFromRadio(): Promise<void> {
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<void> {
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<Uint8Array> {
return this._toDevice;
}
/** Readable stream of {@link Types.DeviceOutput} from the device. */
get fromDevice(): ReadableStream<Types.DeviceOutput> {
return this._fromDevice;
}
/**
* Stop polling and emit `DeviceDisconnected("user")`.
*/
disconnect(): Promise<void> {
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<void> {
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;
}
}
}

187
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<Types.DeviceOutput, { type: "status" }> {
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<Uint8Array, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(chunk);
},
});
const fromDeviceFactory = () =>
new TransformStream<Uint8Array, Types.DeviceOutput>({
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();
});
});

131
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<Uint8Array>;
private readonly _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private port: SerialPort | undefined;
private pipePromise?: Promise<void>;
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<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
this.abortController = new AbortController();
const controller = this.abortController;
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
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<any>`).
toDeviceTransform.readable
.pipeTo(Writable.toWeb(port) as WritableStream<Uint8Array>)
.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<Uint8Array>, {
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 },
});
}
}

186
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<Types.DeviceOutput, { type: "status" }> {
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<Uint8Array, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(chunk);
},
});
const fromDeviceFactory = () =>
new TransformStream<Uint8Array, Types.DeviceOutput>({
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();
});
});

135
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<Uint8Array>;
private readonly _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private socket: Socket | undefined;
private pipePromise?: Promise<void>;
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<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
const transformed = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
this._fromDevice = new ReadableStream<Types.DeviceOutput>({
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<any>`).
toDeviceTransform.readable
.pipeTo(Writable.toWeb(connection) as WritableStream<Uint8Array>)
this.pipePromise = toDeviceTransform.readable
.pipeTo(Writable.toWeb(connection) as WritableStream<Uint8Array>, {
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<Uint8Array> {
return this._toDevice;
}
/**
* The ReadableStream to receive data from the Meshtastic device.
*/
/** ReadableStream to receive data from the device. */
public get fromDevice(): ReadableStream<Types.DeviceOutput> {
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<void> {
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 },
});
}
}

166
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<string, Set<(e: Event) => 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<EventTarget["addEventListener"]>) =>
deviceEmitter.addEventListener(args[0] as string, args[1] as (e: Event) => void),
removeEventListener: (...args: Parameters<EventTarget["removeEventListener"]>) =>
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<typeof stubWebBluetooth> }).__ble?.cleanup();
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble = undefined;
vi.restoreAllMocks();
vi.unstubAllGlobals();
},
create: async () => {
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble = stubWebBluetooth();
return await TransportWebBluetooth.create();
},
pushIncoming: async (bytes) => {
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.pushIncoming(bytes);
await Promise.resolve();
},
assertLastWritten: (bytes) => {
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.assertLastWritten(bytes);
},
triggerDisconnect: async () => {
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.triggerGattDisconnect();
await Promise.resolve();
},
});
});

212
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<Uint8Array>;
private _fromDevice: ReadableStream<Types.DeviceOutput>;
private _fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private _isFirstWrite = true;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
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<TransportWebBluetooth> {
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<TransportWebBluetooth> {
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<TransportWebBluetooth> {
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<Types.DeviceOutput>({
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<Uint8Array>({
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<Uint8Array> {
return this._toDevice;
}
/** Readable stream of {@link Types.DeviceOutput} from the device. */
get fromDevice(): ReadableStream<Types.DeviceOutput> {
return this._fromDevice;
}
protected async readFromRadio(
controller: ReadableStreamDefaultController<Types.DeviceOutput>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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);
}
}

225
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<Uint8Array, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(chunk);
},
});
// maps raw bytes -> DeviceOutput.packet
const fromDeviceFactory = () =>
new TransformStream<Uint8Array, Types.DeviceOutput>({
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<SerialDisconnectHandler>();
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<Uint8Array>;
writable: WritableStream<Uint8Array>;
lastWritten?: Uint8Array;
private _readController!: ReadableStreamDefaultController<Uint8Array>;
constructor() {
this.readable = new ReadableStream<Uint8Array>({
start: (controller) => {
this._readController = controller;
},
});
this.writable = new WritableStream<Uint8Array>({
write: async (chunk) => {
this.lastWritten = chunk;
},
});
}
open(_options?: { baudRate?: number }): Promise<void> {
return Promise.resolve();
}
close(): Promise<void> {
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();
});
});

189
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<Uint8Array>;
private _fromDevice: ReadableStream<Types.DeviceOutput>;
private fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private connection: SerialPort;
private pipePromise: Promise<void> | null = null;
private abortController: AbortController;
private portReadable: ReadableStream<Uint8Array>;
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<TransportWebSerial> {
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<TransportWebSerial> {
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<Types.DeviceOutput>({
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<Uint8Array> {
/** Writable stream of bytes to the device. */
public get toDevice(): WritableStream<Uint8Array> {
return this._toDevice;
}
get fromDevice(): ReadableStream<Types.DeviceOutput> {
/** Readable stream of {@link Types.DeviceOutput} from the device. */
public get fromDevice(): ReadableStream<Types.DeviceOutput> {
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<void> {
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;
}
}
}

4
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": "Прекъсване"

5
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"
}

33
packages/web/public/i18n/locales/bg-BG/dialog.json

@ -75,7 +75,8 @@
"unknownErrorLoadingDevices": "Неизвестна грешка при зареждане на устройствата"
},
"validation": {
"requiresFeatures": "Този тип връзка изисква <0></0>. Моля, използвайте поддържан браузър, като Chrome или Edge.",
"requiresWebBluetooth": "Този тип връзка изисква <0>Web Bluetooth</0>. Моля, използвайте поддържан браузър, като Chrome или Edge.",
"requiresWebSerial": "Този тип връзка изисква <0>Web Serial</0>. Моля, използвайте поддържан браузър, като Chrome или Edge.",
"requiresSecureContext": "Това приложение изисква <0>secure context</0>. Моля, свържете се чрез HTTPS или localhost.",
"additionallyRequiresSecureContext": "Освен това, изисква <0>secure context</0>. Моля, свържете се чрез 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.",
"requiresWebBluetooth": "Dieser Verbindungstyp erfordert <0>Bluetooth</0> im Browser. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.",
"requiresWebSerial": "Dieser Verbindungstyp erfordert <0>Serielle Schnittstelle</0> im Browser. Bitte verwenden Sie einen unterstützten Browser, wie Chrome oder Edge.",
"requiresSecureContext": "Diese Anwendung erfordert einen <0>sicheren Kontext</0>. Bitte verbinden Sie sich über HTTPS oder localhost.",
"additionallyRequiresSecureContext": "Zusätzlich erfordert es einen <0>sicheren Kontext</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Käytä tuettua selainta, kuten Chromea tai Edgeä.",
"requiresWebBluetooth": "Tämä yhteystyyppi vaatii <0>Web-sarjaportti bluetooth</0> -tuen. Käytä tuettua selainta, kuten Chromea tai Edgeä.",
"requiresWebSerial": "Tämä yhteystyyppi vaatii <0>Web-sarjaportti</0> -tuen. Käytä tuettua selainta, kuten Chromea tai Edgeä.",
"requiresSecureContext": "Tämä sovellus vaatii <0>suojatun yhteyden</0>. Yhdistä käyttämällä HTTPS:ää tai localhostia.",
"additionallyRequiresSecureContext": "Lisäksi se vaatii <0>suojatun yhteyden</0>. 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."
}
}

4
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"

5
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"
}

55
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "Ce type de connexion nécessite <0>Web Bluetooth</0>. Veuillez utiliser un navigateur compatible, comme Chrome ou Edge.<0>",
"requiresWebSerial": "Ce type de connexion nécessite <0>Web Serial</0>. Veuillez utiliser un navigateur compatible, comme Chrome ou Edge.",
"requiresSecureContext": "Cette application nécessite un <0>contexte sécurisé</0>. Veuillez vous connecter via HTTPS ou localhost.",
"additionallyRequiresSecureContext": "Elle nécessite également un <0>contexte sécurisé</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Si prega di utilizzare un browser supportato, come Chrome o Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "Questa applicazione richiede un <0>contesto sicuro</0>. Si prega di connettersi utilizzando HTTPS o localhost.",
"additionallyRequiresSecureContext": "Inoltre, richiede un <0>contesto sicuro</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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": "연결 끊기"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Använd en webbläsare som stöds, till exempel Chrome eller Edge.",
"requiresWebBluetooth": "Den här anslutningstypen kräver <0>Web Bluetooth</0>. Använd en webbläsare som stöds, till exempel Chrome eller Edge.",
"requiresWebSerial": "Den här anslutningstypen kräver <0>Web Serial</0>. Använd en webbläsare som stöds, till exempel Chrome eller Edge.",
"requiresSecureContext": "Denna applikation kräver en <0>säker kontext</0>. Anslut med HTTPS eller localhost.",
"additionallyRequiresSecureContext": "Dessutom kräver den ett <0>säker kontext</0>. 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."
}
}

4
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"

5
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"
}

33
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></0> gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.",
"requiresWebBluetooth": "Bu bağlantı tipi <0>Web Bluetooth</0> gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.",
"requiresWebSerial": "Bu bağlantı tipi <0>Web Serial</0> gerektirir. Lütfen desteklenen bir tarayıcı kullan, Chrome yada Edge gibi.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

4
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": "断开"

5
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"
}

33
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></0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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."
}
}

10
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);
}}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<DeviceWrapper deviceId={selectedDeviceId}>
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}

10
packages/web/src/DeviceWrapper.tsx

@ -1,13 +1,15 @@
import { type Device, DeviceContext } from "@core/stores";
import { CurrentDeviceContext } from "@core/stores";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {
children: ReactNode;
device?: Device;
deviceId: number;
}
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => {
return (
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
<CurrentDeviceContext.Provider value={{ deviceId }}>
{children}
</CurrentDeviceContext.Provider>
);
};

14
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();
},
},
{

5
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 = {

4
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 =

5
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();

7
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;

58
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(<RebootDialog open onOpenChange={() => {}} />);
// 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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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(<RebootDialog open onOpenChange={onOpenChangeMock} />);
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();
});

11
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(
<DeviceContext.Provider value={currentDeviceState}>
<CurrentDeviceContext.Provider value={{ deviceId }}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>,
</CurrentDeviceContext.Provider>,
);
expect(container.firstChild).toBeNull();

5
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);

6
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(() => {

5
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 = () => {

4
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);

19
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;

13
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<BluetoothDevice[]>([]);
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);

5
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", () => ({

12
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) {

13
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<SerialPort[]>([]);
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);

10
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");

9
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);
},

4
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 (

18
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();

9
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", () => ({

19
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 };

11
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", () => ({

22
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 };

6
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<AppState>()((set, get) => ({
selectedDevice: 0,
selectedDeviceId: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
@ -76,7 +76,7 @@ export const useAppStore = create<AppState>()((set, get) => ({
},
setSelectedDevice: (deviceId) =>
set(() => ({
selectedDevice: deviceId,
selectedDeviceId: deviceId,
})),
addDevice: (device) =>
set((state) => ({

17
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(),

251
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<Protobuf.Mesh.RouteDiscovery>[]
>;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map<number, number>;
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // 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<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: MeshDevice) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => 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<PrivateDeviceState>((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<PrivateDeviceState>((set, get) => ({
}),
);
},
addNodeInfo: (nodeInfo) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
device.nodesMap.set(nodeInfo.num, nodeInfo);
}),
);
},
setActiveNode: (node) => {
set(
produce<PrivateDeviceState>((draft) => {
@ -600,38 +562,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
addUser: (user) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
@ -665,17 +595,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
removeNode: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
device.nodesMap.delete(nodeNum);
}),
);
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce<PrivateDeviceState>((draft) => {
@ -693,31 +612,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}
return device.dialog[dialog];
},
processPacket(data: ProcessPacketParams) {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
@ -728,40 +623,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
setNodeError: (nodeNum: number, error: string) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
clearNodeError: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
@ -806,48 +667,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((set, get) => ({
getDevice: (id) => get().devices.get(id),
}));
export const DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};

37
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;
};

440
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<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
removeNode: (nodeNum: number) => void;
removeAllNodes: (keepMyNode?: boolean) => void;
processPacket: (data: ProcessPacketParams) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => 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<number, NodeDB>;
}
type NodeDBData = {
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
type NodeDBPersisted = {
nodeDBs: Map<number, NodeDBData>;
};
function nodeDBFactory(
id: number,
get: () => PrivateNodeDBState,
set: typeof useNodeDBStore.setState,
data?: Partial<NodeDBData>,
): NodeDB {
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>();
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>();
const myNodeNum = data?.myNodeNum;
return {
id,
myNodeNum,
nodeMap,
nodeErrors,
addNode: (node) =>
set(
produce<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors.delete(nodeNum);
}),
),
removeAllNodeErrors: () =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors = new Map<number, NodeError>();
}),
),
processPacket: (data) =>
set(
produce<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState>((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<PrivateNodeDBState> = (
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<PrivateNodeDBState>((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<PrivateNodeDBState>((draft) => {
draft.nodeDBs.delete(id);
}),
);
},
getNodeDBs: () => Array.from(get().nodeDBs.values()),
getNodeDB: (id) => get().nodeDBs.get(id),
});
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
name: "meshtastic-nodedb-store",
storage: createStorage<NodeDBPersisted>(),
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<PrivateNodeDBState>((draft) => {
const rebuilt = new Map<number, NodeDB>();
for (const [id, data] of (
draft.nodeDBs as unknown as Map<number, NodeDBData>
).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<PrivateNodeDBState, [["zustand/persist", NodeDBPersisted]]>(
persist(nodeDBInitializer, persistOptions),
)
: createStore<PrivateNodeDBState>()(nodeDBInitializer);

30
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(),
};

258
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<string, string>();
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<string, any> = {}) {
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/);
});
});

16
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 };

158
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<T> = ReturnType<typeof createStorage<T>>;
describe("indexDB.ts persistence (steps 1–5)", () => {
let store: PersistStorage<any>;
beforeEach(() => {
vi.restoreAllMocks();
store = createStorage<any>();
});
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<number,string> and preserves numeric key semantics", async () => {
const m = new Map<number, string>([[1, "a"]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, string>;
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<number, number>([[2, 3]])],
obj: { inner: new Map<string, number>([["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<number, number>).get(2)).toBe(3);
expect(out!.state.obj.inner instanceof Map).toBe(true);
expect((out!.state.obj.inner as Map<string, number>).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<number, { key: Uint8Array }>([[42, inner]]);
const out = await roundTrip({ state: { m }, version: 0 });
const m2 = out!.state.m as Map<number, { key: Uint8Array }>;
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<string, Uint8Array>([["k", new Uint8Array([9])]]),
},
},
],
},
version: 0,
};
const out = await roundTrip(payload);
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>;
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<number, string>;
expect(m2 instanceof Map).toBe(true);
expect(m2.get(1)).toBe("x");
});
});

181
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<string, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
nodeNum: number;
};
export const zustandIndexDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
return (await get(name)) || null;
@ -29,73 +17,118 @@ export const zustandIndexDBStorage: StateStorage = {
},
};
type SerializedMap<K = unknown, V = unknown> = {
__dataType: "Map";
value: Array<[K, V]>;
};
type AnyRecord = Record<string, unknown>;
type JsonReplacer = (key: string, value: unknown) => unknown;
const replacer: JsonReplacer = (_, value) => {
if (value instanceof Map) {
const map = value as Map<unknown, unknown>;
const serialized: SerializedMap = {
__dataType: "Map",
value: Array.from(map.entries()),
};
return serialized;
}
return value;
type Envelope<Tag extends string = string, V = unknown> = {
__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<SerializedMap>;
return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
interface Handler<T, Tag extends string, Encoded> {
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<K = unknown, V = unknown> = Array<[K, V]>;
const mapHandler: Handler<Map<unknown, unknown>, "Map", SerializedMap> = {
tag: "Map",
test: (val): val is Map<unknown, unknown> => val instanceof Map,
serialize: (map) => Array.from(map.entries()),
revive: (pairs) => new Map(pairs),
};
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (
name,
): Promise<StorageValue<PersistedMessageState> | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) {
return null;
}
try {
const parsed = JSON.parse(
str,
reviver,
) as StorageValue<PersistedMessageState>;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
// Uint8Array handler
const uint8ArrayHandler: Handler<Uint8Array, "Uint8Array", number[]> = {
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<H extends Handler<unknown, string, unknown>>(
handlers: readonly H[],
) {
const byTag = new Map<H["tag"], H>();
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<typeof handler.tag, typeof encoded> = {
__datatype: handler.tag,
value: encoded,
};
return envelope;
}
}
},
setItem: async (
name,
newValue: StorageValue<PersistedMessageState>,
): Promise<void> => {
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<H["tag"], unknown>).value as never,
);
}
}
},
removeItem: async (name): Promise<void> => {
await zustandIndexDBStorage.removeItem(name);
},
};
return value;
};
return { replacer, reviver };
}
export function createStorage<
T,
H extends Handler<unknown, string, unknown> = never,
>(extraHandlers: readonly H[] = [] as const): PersistStorage<T> {
const { replacer, reviver } = makeJson([
...defaultHandlers,
...extraHandlers,
]);
return {
getItem: async (name): Promise<StorageValue<T> | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) {
return null;
}
try {
const parsed = JSON.parse(str, reviver) as StorageValue<T>;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
}
},
setItem: async (name, newValue: StorageValue<T>): Promise<void> => {
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<void> => {
await zustandIndexDBStorage.removeItem(name);
},
};
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save