feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
export interface EnumLike {
|
||||
[key: number]: string | number;
|
||||
}
|
||||
|
||||
export const bitwiseEncode = (enumValues: number[]): number => {
|
||||
return enumValues.reduce((acc, curr) => acc | curr, 0);
|
||||
};
|
||||
|
||||
export const bitwiseDecode = (value: number, decodeEnum: EnumLike): number[] => {
|
||||
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean);
|
||||
return enumValues.map((b) => value & b).filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getColorFromNodeNum, hexToRgb, isLightColor, type RGBColor, rgbToHex } from "./color.ts";
|
||||
|
||||
describe("hexToRgb", () => {
|
||||
it.each([
|
||||
[0x000000, { r: 0, g: 0, b: 0 }],
|
||||
[0xffffff, { r: 255, g: 255, b: 255 }],
|
||||
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
|
||||
[0xff8000, { r: 255, g: 128, b: 0 }],
|
||||
])("parses 0x%s correctly", (hex, expected) => {
|
||||
expect(hexToRgb(hex)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
it.each<[RGBColor, number]>([
|
||||
[{ r: 0, g: 0, b: 0 }, 0x000000],
|
||||
[{ r: 255, g: 255, b: 255 }, 0xffffff],
|
||||
[{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456],
|
||||
[{ r: 255, g: 128, b: 0 }, 0xff8000],
|
||||
])("packs %j into 0x%s", (rgb, expected) => {
|
||||
expect(rgbToHex(rgb)).toBe(expected);
|
||||
});
|
||||
|
||||
it("rounds component values before packing", () => {
|
||||
expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe((12 << 16) | (13 << 8) | 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexToRgb ⟷ rgbToHex round-trip", () => {
|
||||
it("is identity for representative values (masked to 24-bit)", () => {
|
||||
const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00];
|
||||
for (const hex of samples) {
|
||||
const rgb = hexToRgb(hex);
|
||||
expect(rgbToHex(rgb)).toBe(hex & 0xffffff);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds for random 24-bit values", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF
|
||||
expect(rgbToHex(hexToRgb(hex))).toBe(hex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLightColor", () => {
|
||||
it("detects obvious extremes", () => {
|
||||
expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white
|
||||
expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black
|
||||
});
|
||||
|
||||
it("respects the 127.5 threshold at boundary", () => {
|
||||
// mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold)
|
||||
expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false);
|
||||
expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getColorFromNodeNum", () => {
|
||||
it.each([
|
||||
[0x000000, { r: 0, g: 0, b: 0 }],
|
||||
[0xffffff, { r: 255, g: 255, b: 255 }],
|
||||
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
|
||||
])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => {
|
||||
expect(getColorFromNodeNum(nodeNum)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("matches hexToRgb when masking to 24 bits", () => {
|
||||
const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456];
|
||||
for (const n of nodeNums) {
|
||||
// JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison.
|
||||
const masked = n & 0xffffff;
|
||||
expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked));
|
||||
}
|
||||
});
|
||||
|
||||
it("always yields components within 0..255", () => {
|
||||
const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31));
|
||||
for (const v of Object.values(color)) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface RGBColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const hexToRgb = (hex: number): RGBColor => ({
|
||||
r: (hex & 0xff0000) >> 16,
|
||||
g: (hex & 0x00ff00) >> 8,
|
||||
b: hex & 0x0000ff,
|
||||
});
|
||||
|
||||
export const rgbToHex = (c: RGBColor): number =>
|
||||
(Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
|
||||
|
||||
export const isLightColor = (c: RGBColor): boolean =>
|
||||
(c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5;
|
||||
|
||||
export const getColorFromNodeNum = (nodeNum: number): RGBColor => {
|
||||
// Extract RGB values directly from nodeNum (treated as hex color)
|
||||
const r = (nodeNum & 0xff0000) >> 16;
|
||||
const g = (nodeNum & 0x00ff00) >> 8;
|
||||
const b = nodeNum & 0x0000ff;
|
||||
|
||||
return { r, g, b };
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { debounce } from "./debounce.ts";
|
||||
|
||||
describe("debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("delays executing the callback until after wait time has elapsed", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("only executes the callback once if called multiple times within wait period", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
debouncedFunction();
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the timer when called again during wait period", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes arguments to the callback function", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction("test", 123);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(mockCallback).toHaveBeenCalledWith("test", 123);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
type Callback<Args extends unknown[]> = (...args: Args) => void;
|
||||
|
||||
export function debounce<Args extends unknown[]>(
|
||||
callback: Callback<Args>,
|
||||
wait: number,
|
||||
): Callback<Args> {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepCompareConfig } from "./deepCompareConfig.ts";
|
||||
|
||||
describe("deepCompareConfig", () => {
|
||||
it("returns true for identical primitives", () => {
|
||||
expect(deepCompareConfig(5, 5)).toBe(true);
|
||||
expect(deepCompareConfig("foo", "foo")).toBe(true);
|
||||
expect(deepCompareConfig(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different primitives", () => {
|
||||
expect(deepCompareConfig(5, 6)).toBe(false);
|
||||
expect(deepCompareConfig("foo", "bar")).toBe(false);
|
||||
expect(deepCompareConfig(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nulls correctly", () => {
|
||||
expect(deepCompareConfig(null, null)).toBe(true);
|
||||
expect(deepCompareConfig(null, undefined)).toBe(false);
|
||||
expect(deepCompareConfig(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("allows undefined in working when allowUndefined is true", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects undefined in working when allowUndefined is false", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares arrays deeply", () => {
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
|
||||
});
|
||||
|
||||
it("compares objects deeply", () => {
|
||||
const existing = { x: 10, y: { z: 20 } };
|
||||
const workingEqual = { x: 10, y: { z: 20 } };
|
||||
const workingDiff = { x: 10, y: { z: 21 } };
|
||||
|
||||
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
|
||||
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores $typeName key in existing", () => {
|
||||
const existing = { $typeName: "Test", a: 1 };
|
||||
const working = { a: 1 };
|
||||
expect(deepCompareConfig(existing, working)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when working has extra keys", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows working arrays to be shorter if allowUndefined is true", () => {
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares Uint8Array strictly: equal bytes -> true", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 3]);
|
||||
expect(deepCompareConfig(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("compares Uint8Array strictly: different bytes -> false", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 4]);
|
||||
expect(deepCompareConfig(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("Uint8Array vs undefined is false even when allowUndefined is true", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
expect(deepCompareConfig(a, undefined, true)).toBe(false);
|
||||
expect(deepCompareConfig(undefined, a, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("nested Uint8Array fields must match exactly", () => {
|
||||
const existing = { data: new Uint8Array([9, 8, 7]) };
|
||||
const workingEqual = { data: new Uint8Array([9, 8, 7]) };
|
||||
const workingDiff = { data: new Uint8Array([9, 8, 6]) };
|
||||
const workingUndef = { data: undefined as unknown };
|
||||
|
||||
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
|
||||
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
|
||||
// still false even with allowUndefined
|
||||
expect(deepCompareConfig(existing, workingUndef, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("arrays containing Uint8Array: element must match exactly", () => {
|
||||
const a = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const b = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const c = [new Uint8Array([1, 2]), new Uint8Array([3, 9])];
|
||||
|
||||
expect(deepCompareConfig(a, b)).toBe(true);
|
||||
expect(deepCompareConfig(a, c)).toBe(false);
|
||||
});
|
||||
|
||||
it("shorter working array with missing Uint8Array element -> false even with allowUndefined", () => {
|
||||
const existing = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const workingShort = [new Uint8Array([1, 2])]; // missing the second byte array
|
||||
expect(deepCompareConfig(existing, workingShort, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isUint8Array(v: unknown): v is Uint8Array {
|
||||
return v instanceof Uint8Array;
|
||||
}
|
||||
|
||||
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deepCompareConfig(a: unknown, b: unknown, allowUndefined = false): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isUint8Array(a) || isUint8Array(b)) {
|
||||
return isUint8Array(a) && isUint8Array(b) && bytesEqual(a, b);
|
||||
}
|
||||
|
||||
if (allowUndefined && (a === undefined || b === undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a !== typeof b || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length && !allowUndefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const longestLength = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < longestLength; i++) {
|
||||
if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isObject(a) && isObject(b)) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
const allKeys = new Set([...aKeys, ...bKeys]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === "$typeName") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dotPaths } from "./dotPath.ts";
|
||||
|
||||
describe("dotPaths", () => {
|
||||
it("returns flat keys for a simple object", () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
expect(dotPaths(obj)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns dot notation keys for nested objects", () => {
|
||||
const obj = { a: { b: { c: 1 } }, d: 2 };
|
||||
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]);
|
||||
});
|
||||
|
||||
it("handles arrays at the root", () => {
|
||||
const arr = [{ x: 1 }, { y: 2 }];
|
||||
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]);
|
||||
});
|
||||
|
||||
it("handles arrays nested in objects", () => {
|
||||
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 };
|
||||
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]);
|
||||
});
|
||||
|
||||
it("handles objects nested in arrays", () => {
|
||||
const arr = [{ a: { b: 1 } }, { c: 2 }];
|
||||
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]);
|
||||
});
|
||||
|
||||
it("handles primitive values in arrays", () => {
|
||||
const arr = [1, { a: 2 }, 3];
|
||||
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]);
|
||||
});
|
||||
|
||||
it("handles empty objects and arrays", () => {
|
||||
expect(dotPaths({})).toEqual([]);
|
||||
expect(dotPaths([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles mixed nested structures", () => {
|
||||
const obj = {
|
||||
a: [{ b: 1, c: [2, 3] }, { d: { e: 4 } }],
|
||||
f: 5,
|
||||
};
|
||||
expect(dotPaths(obj)).toEqual(["a.0.b", "a.0.c.0", "a.0.c.1", "a.1.d.e", "f"]);
|
||||
});
|
||||
|
||||
it("handles prefix argument", () => {
|
||||
const obj = { a: { b: 1 } };
|
||||
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]);
|
||||
});
|
||||
|
||||
it("skips null and undefined values", () => {
|
||||
const obj = { a: null, b: undefined, c: { d: 1 } };
|
||||
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type DotPath = { [key: string]: unknown } | unknown[];
|
||||
|
||||
export const dotPaths = <T extends DotPath>(obj: T, prefix = ""): string[] => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.flatMap((v, i) =>
|
||||
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${i}.`) : [`${prefix}${i}`],
|
||||
);
|
||||
}
|
||||
return Object.entries(obj).flatMap(([k, v]) =>
|
||||
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${k}.`) : [`${prefix}${k}`],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("EventBus", () => {
|
||||
beforeEach(() => {
|
||||
// Reset event listeners before each test
|
||||
eventBus.offAll();
|
||||
});
|
||||
|
||||
it("should register an event listener and trigger it on emit", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should remove an event listener with off", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return an unsubscribe function from on", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
|
||||
unsubscribe();
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow multiple listeners for the same event", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should only remove the specific listener when off is called", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback1).not.toHaveBeenCalled();
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
|
||||
});
|
||||
|
||||
it("should not fail when calling off on a non-existent listener", () => {
|
||||
const mockCallback = vi.fn();
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export type EventMap = {
|
||||
"dialog:unsafeRoles": {
|
||||
action: "confirm" | "dismiss";
|
||||
};
|
||||
};
|
||||
|
||||
export type EventName = keyof EventMap;
|
||||
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
|
||||
|
||||
class EventBus {
|
||||
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
|
||||
|
||||
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
|
||||
this.listeners[event]?.push(callback);
|
||||
|
||||
return () => {
|
||||
this.off(event, callback);
|
||||
};
|
||||
}
|
||||
|
||||
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
|
||||
if (!this.listeners[event]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackIndex = this.listeners[event]?.indexOf(callback);
|
||||
if (callbackIndex !== undefined && callbackIndex > -1) {
|
||||
this.listeners[event]?.splice(callbackIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public offAll<T extends EventName>(event?: T): void {
|
||||
if (event) {
|
||||
this.listeners[event] = [];
|
||||
} else {
|
||||
this.listeners = {};
|
||||
}
|
||||
}
|
||||
|
||||
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
|
||||
if (!this.listeners[event]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[event]?.forEach((callback) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
@@ -0,0 +1,80 @@
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
|
||||
export type LngLat = [number, number];
|
||||
export type Mercator = [number, number];
|
||||
export type Bounds = [[number, number], [number, number]];
|
||||
|
||||
const INT_DEG = 1e7;
|
||||
const EARTH_RADIUS = 6378137;
|
||||
|
||||
export const toLngLat = (position?: { latitudeI?: number; longitudeI?: number }): LngLat => [
|
||||
(position?.longitudeI ?? 0) / INT_DEG,
|
||||
(position?.latitudeI ?? 0) / INT_DEG,
|
||||
];
|
||||
|
||||
export const hasPos = (position?: { latitudeI?: number; longitudeI?: number }) =>
|
||||
Number.isFinite(position?.latitudeI) &&
|
||||
Number.isFinite(position?.longitudeI) &&
|
||||
!(position?.latitudeI === 0 && position?.longitudeI === 0);
|
||||
|
||||
export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => {
|
||||
if (coords.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turfCoords = coords.map(([lng, lat]) => [lat, lng]);
|
||||
const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords));
|
||||
|
||||
return [
|
||||
[minLng, minLat],
|
||||
[maxLng, maxLat],
|
||||
];
|
||||
};
|
||||
|
||||
const deg2rad = (d: number) => (d * Math.PI) / 180;
|
||||
const rad2deg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
export function lngLatToMercator([lng, lat]: LngLat): Mercator {
|
||||
return [
|
||||
EARTH_RADIUS * deg2rad(lng),
|
||||
EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)),
|
||||
];
|
||||
}
|
||||
|
||||
export function mercatorToLngLat([x, y]: Mercator): LngLat {
|
||||
return [
|
||||
rad2deg(x / EARTH_RADIUS),
|
||||
rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2),
|
||||
];
|
||||
}
|
||||
|
||||
export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) {
|
||||
const phi1 = deg2rad(lat1),
|
||||
phi2 = deg2rad(lat2);
|
||||
const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5);
|
||||
const y = phi2 - phi1;
|
||||
return EARTH_RADIUS * Math.hypot(x, y);
|
||||
}
|
||||
|
||||
export function precisionBitsToMeters(precisionBits: number): number {
|
||||
const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg
|
||||
|
||||
const stepInt = 2 ** (32 - precisionBits);
|
||||
const stepDegrees = stepInt / INT_DEG;
|
||||
return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ);
|
||||
}
|
||||
|
||||
export function bearingDegrees(from: LngLat, to: LngLat): number {
|
||||
const [lambda1deg, phi1deg] = from;
|
||||
const [lambda2deg, phi2deg] = to;
|
||||
|
||||
const phi1 = deg2rad(phi1deg);
|
||||
const phi2 = deg2rad(phi2deg);
|
||||
const deltaLambda = deg2rad(lambda2deg - lambda1deg);
|
||||
|
||||
const y = Math.sin(deltaLambda) * Math.cos(phi2);
|
||||
const x =
|
||||
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
|
||||
|
||||
return (rad2deg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
interface RepoIdentifier {
|
||||
user: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
interface GithubIssueUrlOptions extends Partial<RepoIdentifier> {
|
||||
repoUrl?: string;
|
||||
body?: string;
|
||||
title?: string;
|
||||
labels?: string[];
|
||||
template?: string;
|
||||
assignee?: string;
|
||||
projects?: string[];
|
||||
logs?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
type ValidatedOptions = {
|
||||
repoUrl: string;
|
||||
} & Omit<GithubIssueUrlOptions, "repoUrl" | "user" | "repo">;
|
||||
|
||||
const VALID_PARAMS = [
|
||||
"body",
|
||||
"title",
|
||||
"labels",
|
||||
"template",
|
||||
"assignee",
|
||||
"projects",
|
||||
"version",
|
||||
"logs",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generates a URL for creating a new GitHub issue
|
||||
* @param options Configuration options for the GitHub issue URL
|
||||
* @returns A formatted URL string for creating a new GitHub issue
|
||||
* @throws {Error} If repository information is missing or invalid
|
||||
* @throws {TypeError} If labels or projects are not arrays when provided
|
||||
*/
|
||||
export default function newGithubIssueUrl(options: GithubIssueUrlOptions = {}): string {
|
||||
const validatedOptions = validateOptions(options);
|
||||
const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
|
||||
|
||||
for (const key of VALID_PARAMS) {
|
||||
const value = validatedOptions[key];
|
||||
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === "labels" || key === "projects") && Array.isArray(value)) {
|
||||
url.searchParams.set(key, value.join(","));
|
||||
continue;
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
|
||||
const repoUrl =
|
||||
options.repoUrl ??
|
||||
(options.user && options.repo
|
||||
? `https://github.com/${options.user}/${options.repo}`
|
||||
: undefined);
|
||||
|
||||
if (!repoUrl) {
|
||||
throw new Error(
|
||||
"You need to specify either the `repoUrl` option or both the `user` and `repo` options",
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of ["labels", "projects"] as const) {
|
||||
const value = options[key];
|
||||
if (value !== undefined && !Array.isArray(value)) {
|
||||
throw new TypeError(`The \`${key}\` option should be an array`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
repoUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertIntToIpAddress, convertIpAddressToInt } from "./ip.ts";
|
||||
|
||||
describe("IP Address Conversion Functions", () => {
|
||||
describe("convertIntToIpAddress", () => {
|
||||
it("converts 0 to 0.0.0.0", () => {
|
||||
expect(convertIntToIpAddress(0)).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("converts 16_777_343 to 127.0.0.1", () => {
|
||||
expect(convertIntToIpAddress(16_777_343)).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts 16_820_416 to 192.168.0.1", () => {
|
||||
expect(convertIntToIpAddress(16_820_416)).toBe("192.168.0.1");
|
||||
});
|
||||
|
||||
it("converts 4_294_967_295 to 255.255.255.255", () => {
|
||||
expect(convertIntToIpAddress(4_294_967_295)).toBe("255.255.255.255");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertIpAddressToInt", () => {
|
||||
it("converts 0.0.0.0 to 0", () => {
|
||||
expect(convertIpAddressToInt("0.0.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("converts 127.0.0.1 to 16_777_343", () => {
|
||||
expect(convertIpAddressToInt("127.0.0.1")).toBe(16_777_343);
|
||||
});
|
||||
|
||||
it("converts 192.168.0.1 to 16_820_416", () => {
|
||||
expect(convertIpAddressToInt("192.168.0.1")).toBe(16_820_416);
|
||||
});
|
||||
|
||||
it("converts 255.255.255.255 to 4_294_967_295", () => {
|
||||
expect(convertIpAddressToInt("255.255.255.255")).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it("handles non-standard formats", () => {
|
||||
expect(convertIpAddressToInt("1.2.3.4")).toBe(67_305_985);
|
||||
});
|
||||
|
||||
it("handles invalid IP addresses gracefully", () => {
|
||||
expect(convertIpAddressToInt("300.1.2.3")).not.toBeNull();
|
||||
expect(typeof convertIpAddressToInt("300.1.2.3")).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional conversion", () => {
|
||||
it("can convert back and forth", () => {
|
||||
const testIps = ["0.0.0.0", "127.0.0.1", "192.168.1.1", "10.0.0.1", "255.255.255.255"];
|
||||
|
||||
for (const ip of testIps) {
|
||||
const int = convertIpAddressToInt(ip);
|
||||
expect(int).not.toBeNull();
|
||||
if (int !== null && typeof int === "number") {
|
||||
const convertedBack = convertIntToIpAddress(int);
|
||||
expect(convertedBack).toBe(ip);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function convertIntToIpAddress(int: number): string {
|
||||
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff}`;
|
||||
}
|
||||
|
||||
export function convertIpAddressToInt(ip: string): number | undefined {
|
||||
if (!ip) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
ip
|
||||
.split(".")
|
||||
.reverse()
|
||||
.reduce((ipnum, octet) => {
|
||||
return (ipnum << 8) + Number.parseInt(octet, 10);
|
||||
}, 0) >>> 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randId } from "./randId.ts";
|
||||
|
||||
describe("randId", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a number", () => {
|
||||
const result = randId();
|
||||
expect(typeof result).toBe("number");
|
||||
});
|
||||
|
||||
it("returns an integer", () => {
|
||||
const result = randId();
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses Math.random to generate the number", () => {
|
||||
const mockRandom = vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
const result = randId();
|
||||
|
||||
expect(mockRandom).toHaveBeenCalled();
|
||||
expect(result).toBe(Math.floor(0.5 * 1e9));
|
||||
});
|
||||
|
||||
it("returns a value between 0 and 1e9 (exclusive)", () => {
|
||||
const result = randId();
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(1e9);
|
||||
});
|
||||
|
||||
it("returns different values on subsequent calls", () => {
|
||||
vi.spyOn(Math, "random").mockRestore();
|
||||
|
||||
const results = new Set();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
results.add(randId());
|
||||
}
|
||||
|
||||
expect(results.size).toBeGreaterThan(95);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const randId = () => {
|
||||
return Math.floor(Math.random() * 1e9);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export const SNR_THRESHOLD = {
|
||||
GOOD: -7,
|
||||
FAIR: -15,
|
||||
};
|
||||
|
||||
export const RSSI_THRESHOLD = {
|
||||
GOOD: -115,
|
||||
FAIR: -126,
|
||||
};
|
||||
|
||||
export const LINE_COLOR = {
|
||||
GOOD: "#00ff00",
|
||||
FAIR: "#ffe600",
|
||||
BAD: "#f7931a",
|
||||
};
|
||||
|
||||
export const getSignalColor = (snr: number, rssi?: number): string => {
|
||||
if (snr > SNR_THRESHOLD.GOOD && (rssi == null || rssi > RSSI_THRESHOLD.GOOD)) {
|
||||
return LINE_COLOR.GOOD;
|
||||
}
|
||||
if (snr > SNR_THRESHOLD.FAIR && (rssi == null || rssi > RSSI_THRESHOLD.FAIR)) {
|
||||
return LINE_COLOR.FAIR;
|
||||
}
|
||||
return LINE_COLOR.BAD;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export function intlSort<T extends PropertyKey>(
|
||||
arr: T[],
|
||||
locale: Intl.Locale,
|
||||
order: "asc" | "desc" = "asc",
|
||||
): T[] {
|
||||
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
||||
|
||||
return arr.sort((a, b) => {
|
||||
const stringA = String(a);
|
||||
const stringB = String(b);
|
||||
|
||||
if (order === "asc") {
|
||||
return collator.compare(stringA, stringB);
|
||||
}
|
||||
return collator.compare(stringB, stringA);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
interface PluralForms {
|
||||
one: string;
|
||||
other: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface FormatOptions {
|
||||
locale?: string;
|
||||
pluralRules?: Intl.PluralRulesOptions;
|
||||
numberFormat?: Intl.NumberFormatOptions;
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number, forms: PluralForms, options: FormatOptions = {}) {
|
||||
const {
|
||||
locale = "en-US",
|
||||
pluralRules: pluralOptions = { type: "cardinal" },
|
||||
numberFormat: numberOptions = {},
|
||||
} = options;
|
||||
|
||||
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
|
||||
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
|
||||
|
||||
const pluralCategory = pluralRules.select(value);
|
||||
const word = forms[pluralCategory];
|
||||
|
||||
return `${numberFormat.format(value)} ${word}`;
|
||||
}
|
||||
|
||||
export interface LengthValidationResult {
|
||||
isValid: boolean;
|
||||
currentLength: number | null;
|
||||
}
|
||||
|
||||
export function validateMaxByteLength(
|
||||
value: string | null | undefined,
|
||||
maxByteLength: number,
|
||||
): LengthValidationResult {
|
||||
// Ensure maxByteLength is valid
|
||||
if (typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
|
||||
console.warn("validateMaxByteLength: maxByteLength must be a non-negative integer.");
|
||||
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
|
||||
}
|
||||
|
||||
// Handle null or undefined input values
|
||||
if (value === null || value === undefined) {
|
||||
return { isValid: false, currentLength: null };
|
||||
}
|
||||
|
||||
// Check for TextEncoder availability
|
||||
if (typeof TextEncoder === "undefined") {
|
||||
console.error("validateMaxByteLength: TextEncoder API is not available in this environment.");
|
||||
return { isValid: false, currentLength: null }; // Cannot determine byte length
|
||||
}
|
||||
|
||||
try {
|
||||
// Encode the string to UTF-8 bytes and get the length
|
||||
const encoder = new TextEncoder();
|
||||
const currentLength = encoder.encode(value).length;
|
||||
// Perform the byte length check
|
||||
const isValid = currentLength <= maxByteLength;
|
||||
|
||||
// Return the result object
|
||||
return { isValid, currentLength };
|
||||
} catch (error) {
|
||||
// Handle potential errors during encoding
|
||||
console.error("validateMaxByteLength: Error encoding string:", error);
|
||||
return { isValid: false, currentLength: null }; // Encoding failed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
|
||||
export function getX25519PrivateKey(): Uint8Array {
|
||||
const key = x25519.utils.randomPrivateKey();
|
||||
|
||||
// scalar clamping for curve25519, according to
|
||||
// https://www.rfc-editor.org/rfc/rfc7748#section-5
|
||||
key[0] &= 248;
|
||||
key[31] &= 127;
|
||||
key[31] |= 64;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return x25519.getPublicKey(privateKey);
|
||||
}
|
||||
Reference in New Issue
Block a user