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:
Ben Meadors
2026-06-15 13:23:28 -05:00
committed by GitHub
parent 7a8529b841
commit bbe9a0d5cd
695 changed files with 27038 additions and 786 deletions
+135
View File
@@ -0,0 +1,135 @@
import { fromByteArray } from "base64-js";
import { describe, expect, it } from "vitest";
import { makeChannelSchema } from "./channel.ts";
const mockRole = 0;
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("makeChannelSchema", () => {
const allowedBytes = 16;
const schema = makeChannelSchema(allowedBytes);
const validBase64 = makeBase64OfLength(allowedBytes);
const validSettings = {
channelNum: 3,
psk: validBase64,
name: "TestName",
id: 3,
uplinkEnabled: true,
downlinkEnabled: false,
moduleSettings: { positionPrecision: 10 },
};
it("accepts valid channel object", () => {
const result = schema.safeParse({
index: 0,
settings: validSettings,
role: mockRole,
});
expect(result.success).toBe(true);
});
it("rejects invalid base64 psk", () => {
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, psk: "not_base64!" },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) => i.path.includes("settings") && i.path.includes("psk")),
).toBe(true);
}
});
it("rejects psk of wrong length", () => {
const wrongLength = makeBase64OfLength(8);
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, psk: wrongLength },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) => i.path.includes("settings") && i.path.includes("psk")),
).toBe(true);
}
});
it("rejects name longer than 12 bytes", () => {
const longName = "a".repeat(13);
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, name: longName },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) => i.path.includes("settings") && i.path.includes("name")),
).toBe(true);
}
});
it("rejects channelNum out of range", () => {
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, channelNum: 10 },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(i) => i.path.includes("settings") && i.path.includes("channelNum"),
),
).toBe(true);
}
});
it("rejects missing required fields", () => {
const result = schema.safeParse({
index: 0,
settings: {},
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => {
for (const val of [0, 10, 15, 19, 32]) {
const result = schema.safeParse({
index: 0,
settings: {
...validSettings,
moduleSettings: { positionPrecision: val },
},
role: mockRole,
});
expect(result.success).toBe(true);
}
});
it("rejects moduleSettings.positionPrecision out of range", () => {
for (const val of [9, 20, 31, 33]) {
const result = schema.safeParse({
index: 0,
settings: {
...validSettings,
moduleSettings: { positionPrecision: val },
},
role: mockRole,
});
expect(result.success).toBe(false);
}
});
});
+40
View File
@@ -0,0 +1,40 @@
import { validateMaxByteLength } from "@core/utils/string.ts";
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
import { makePskHelpers } from "./pskSchema.ts";
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
const moduleSettingsSchema = z.object({
positionPrecision: z.union([
z.literal(0),
z.coerce.number().int().min(10).max(19),
z.literal(32),
]),
});
export function makeChannelSchema(allowedBytes: number) {
const { stringSchema } = makePskHelpers([allowedBytes]);
const ChannelSettingsSchema = z.object({
channelNum: z.coerce.number().int().min(0).max(7),
psk: stringSchema(false),
name: z.string().refine((s) => validateMaxByteLength(s, 12).isValid, {
message: "formValidation.tooBig.bytes",
params: { maximum: 12 },
}),
id: z.coerce.number().int(),
uplinkEnabled: z.boolean(),
downlinkEnabled: z.boolean(),
moduleSettings: moduleSettingsSchema,
});
return z.object({
index: z.coerce.number(),
settings: ChannelSettingsSchema,
role: RoleEnum,
});
}
const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes
export type ChannelValidation = z.infer<typeof ChannelValidationSchema>;
@@ -0,0 +1,12 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const PairingModeEnum = z.enum(Protobuf.Config.Config_BluetoothConfig_PairingMode);
export const BluetoothValidationSchema = z.object({
enabled: z.boolean(),
mode: PairingModeEnum,
fixedPin: z.coerce.number().int().min(100000).max(999999),
});
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>;
+21
View File
@@ -0,0 +1,21 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const RoleEnum = z.enum(Protobuf.Config.Config_DeviceConfig_Role);
const RebroadcastModeEnum = z.enum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode);
export const DeviceValidationSchema = z.object({
role: RoleEnum,
serialEnabled: z.boolean(),
buttonGpio: z.coerce.number().int().min(0),
buzzerGpio: z.coerce.number().int().min(0),
rebroadcastMode: RebroadcastModeEnum,
nodeInfoBroadcastSecs: z.coerce.number().int().min(0),
doubleTapAsButtonPress: z.boolean(),
isManaged: z.boolean(),
disableTripleClick: z.boolean(),
ledHeartbeatDisabled: z.boolean(),
tzdef: z.string().max(65),
});
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>;
+28
View File
@@ -0,0 +1,28 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const GpsCoordinateEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_DeprecatedGpsCoordinateFormat,
);
const DisplayUnitsEnum = z.enum(Protobuf.Config.Config_DisplayConfig_DisplayUnits);
const OledTypeEnum = z.enum(Protobuf.Config.Config_DisplayConfig_OledType);
const DisplayModeEnum = z.enum(Protobuf.Config.Config_DisplayConfig_DisplayMode);
const CompassOrientationEnum = z.enum(Protobuf.Config.Config_DisplayConfig_CompassOrientation);
export const DisplayValidationSchema = z.object({
screenOnSecs: z.coerce.number().int().min(0),
gpsFormat: GpsCoordinateEnum,
autoScreenCarouselSecs: z.coerce.number().int().min(0),
compassNorthTop: z.boolean(),
flipScreen: z.boolean(),
units: DisplayUnitsEnum,
oled: OledTypeEnum,
displaymode: DisplayModeEnum,
headingBold: z.boolean(),
wakeOnTapOrMotion: z.boolean(),
compassOrientation: CompassOrientationEnum,
use12hClock: z.boolean(),
enableMessageBubbles: z.boolean(),
});
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>;
@@ -0,0 +1,13 @@
import { Protobuf } from "@meshtastic/core";
import { describe, expect, it } from "vitest";
describe("LoRa modem presets", () => {
it("includes LONG_TURBO modem preset", () => {
expect(
Object.prototype.hasOwnProperty.call(
Protobuf.Config.Config_LoRaConfig_ModemPreset,
"LONG_TURBO",
),
).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const ModemPresetEnum = z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset);
const RegionCodeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode);
const FemLnaModeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode);
export const LoRaValidationSchema = z.object({
usePreset: z.boolean(),
modemPreset: ModemPresetEnum,
bandwidth: z.coerce.number().int(),
spreadFactor: z.coerce.number().int().max(12),
codingRate: z.coerce.number().int().min(0).max(10),
frequencyOffset: z.coerce.number().int(),
region: RegionCodeEnum,
hopLimit: z.coerce.number().int().min(0).max(7),
txEnabled: z.boolean(),
txPower: z.coerce.number().int().min(0),
channelNum: z.coerce.number().int(),
overrideDutyCycle: z.boolean(),
sx126xRxBoostedGain: z.boolean(),
overrideFrequency: z.coerce.number().refine((val) => val === 0 || (val >= 410 && val <= 930), {
message: "formValidation.invalidOverrideFreq.number",
}),
ignoreIncoming: z.coerce.number().array(),
ignoreMqtt: z.boolean(),
configOkToMqtt: z.boolean(),
femLnaMode: FemLnaModeEnum,
serialHalOnly: z.boolean(),
});
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;
+26
View File
@@ -0,0 +1,26 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const AddressModeEnum = z.enum(Protobuf.Config.Config_NetworkConfig_AddressMode);
const ProtocolFlagsEnum = z.enum(Protobuf.Config.Config_NetworkConfig_ProtocolFlags);
export const NetworkValidationIpV4ConfigSchema = z.object({
ip: z.ipv4(),
gateway: z.ipv4(),
subnet: z.ipv4(),
dns: z.ipv4(),
});
export const NetworkValidationSchema = z.object({
wifiEnabled: z.boolean(),
wifiSsid: z.string().max(33),
wifiPsk: z.string().max(64),
ntpServer: z.string().min(0).max(33),
ethEnabled: z.boolean(),
addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema,
enabledProtocols: ProtocolFlagsEnum,
rsyslogServer: z.string().max(33),
});
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
@@ -0,0 +1,23 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const GpsModeEnum = z.enum(Protobuf.Config.Config_PositionConfig_GpsMode);
export const PositionValidationSchema = z.object({
positionBroadcastSecs: z.coerce.number().int().min(0),
positionBroadcastSmartEnabled: z.boolean(),
fixedPosition: z.boolean(),
gpsUpdateInterval: z.coerce.number().int().min(0),
positionFlags: z.coerce.number().int().min(0),
rxGpio: z.coerce.number().int().min(0),
txGpio: z.coerce.number().int().min(0),
broadcastSmartMinimumDistance: z.coerce.number().int().min(0),
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
gpsEnGpio: z.coerce.number().int().min(0),
gpsMode: GpsModeEnum,
latitude: z.coerce.number().min(-90).max(90).optional(),
longitude: z.coerce.number().min(-180).max(180).optional(),
altitude: z.coerce.number().optional(),
});
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export const PowerValidationSchema = z.object({
isPowerSaving: z.boolean(),
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0),
adcMultiplierOverride: z.coerce.number().min(0).max(4),
waitBluetoothSecs: z.coerce.number().int().min(0),
sdsSecs: z.coerce.number().int().min(0),
lsSecs: z.coerce.number().int().min(0),
minWakeSecs: z.coerce.number().int().min(0),
deviceBatteryInaAddress: z.coerce.number().int().min(0),
});
export type PowerValidation = z.infer<typeof PowerValidationSchema>;
@@ -0,0 +1,106 @@
import { fromByteArray } from "base64-js";
import { describe, expect, it } from "vitest";
import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts";
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("RawSecuritySchema", () => {
const validKey = makeBase64OfLength(32);
it("accepts valid security config", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(true);
});
it("rejects if privateKey is invalid", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: "badkey",
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.path.includes("privateKey"))).toBe(true);
}
});
it("requires at least one adminKey if isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: ["", "", ""],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "formValidation.required.managed")).toBe(
true,
);
}
});
it("accepts if at least one adminKey is valid when isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(true);
});
});
describe("ParsedSecuritySchema", () => {
const validKey = new Uint8Array(32);
it("accepts valid parsed security config", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, new Uint8Array(), new Uint8Array()],
});
expect(result.success).toBe(true);
});
it("requires at least one adminKey if isManaged", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "formValidation.required.managed")).toBe(
true,
);
}
});
});
@@ -0,0 +1,41 @@
import { type ZodType, z } from "zod/v4";
import { makePskHelpers } from "./../pskSchema.ts";
const { stringSchema, bytesSchema, isValidKey } = makePskHelpers([32]); // 256-bit
const isManagedRequiredMsg = "formValidation.required.managed";
function makeSecuritySchema<KeyT>(keyMaker: (optional: boolean) => ZodType<KeyT>) {
return z
.object({
isManaged: z.boolean(),
adminChannelEnabled: z.boolean(),
debugLogApiEnabled: z.boolean(),
serialEnabled: z.boolean(),
privateKey: keyMaker(false),
publicKey: keyMaker(false),
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]),
})
.check((ctx) => {
if (ctx.value.isManaged) {
const hasAdmin = ctx.value.adminKey.some(isValidKey);
if (!hasAdmin) {
for (const path of [["isManaged"], ["adminKey", 0]] as const) {
ctx.issues.push({
code: "custom",
message: isManagedRequiredMsg,
path: [...path],
input: ctx.value,
});
}
}
}
});
}
export const RawSecuritySchema = makeSecuritySchema(stringSchema);
export type RawSecurity = z.infer<typeof RawSecuritySchema>;
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema);
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>;
+17
View File
@@ -0,0 +1,17 @@
import { t } from "i18next";
import { z } from "zod/v4";
export const UserValidationSchema = z.object({
longName: z
.string()
.min(1, t("user.longName.validation.min"))
.max(40, t("user.longName.validation.max")),
shortName: z
.string()
.min(2, t("user.shortName.validation.min"))
.max(4, t("user.shortName.validation.max")),
isUnmessageable: z.boolean().default(false),
isLicensed: z.boolean().default(false),
});
export type UserValidation = z.infer<typeof UserValidationSchema>;
@@ -0,0 +1,11 @@
import { z } from "zod/v4";
export const AmbientLightingValidationSchema = z.object({
ledState: z.boolean(),
current: z.coerce.number().int().min(0),
red: z.coerce.number().int().min(0).max(255),
green: z.coerce.number().int().min(0).max(255),
blue: z.coerce.number().int().min(0).max(255),
});
export type AmbientLightingValidation = z.infer<typeof AmbientLightingValidationSchema>;
@@ -0,0 +1,16 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const Audio_BaudEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud);
export const AudioValidationSchema = z.object({
codec2Enabled: z.boolean(),
pttPin: z.coerce.number().int().min(0),
bitrate: Audio_BaudEnum,
i2sWs: z.coerce.number().int().min(0),
i2sSd: z.coerce.number().int().min(0),
i2sDin: z.coerce.number().int().min(0),
i2sSck: z.coerce.number().int().min(0),
});
export type AudioValidation = z.infer<typeof AudioValidationSchema>;
@@ -0,0 +1,22 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const InputEventCharEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
);
export const CannedMessageValidationSchema = z.object({
rotary1Enabled: z.boolean(),
inputbrokerPinA: z.coerce.number().int().min(0),
inputbrokerPinB: z.coerce.number().int().min(0),
inputbrokerPinPress: z.coerce.number().int().min(0),
inputbrokerEventCw: InputEventCharEnum,
inputbrokerEventCcw: InputEventCharEnum,
inputbrokerEventPress: InputEventCharEnum,
updown1Enabled: z.boolean(),
enabled: z.boolean(),
allowInputSource: z.string().max(30),
sendBell: z.boolean(),
});
export type CannedMessageValidation = z.infer<typeof CannedMessageValidationSchema>;
@@ -0,0 +1,19 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const detectionTriggerTypeEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
);
export const DetectionSensorValidationSchema = z.object({
enabled: z.boolean(),
minimumBroadcastSecs: z.coerce.number().int().min(0),
stateBroadcastSecs: z.coerce.number().int().min(0),
sendBell: z.boolean(),
name: z.string().min(0).max(20),
monitorPin: z.coerce.number().int().min(0),
detectionTriggerType: detectionTriggerTypeEnum,
usePullup: z.boolean(),
});
export type DetectionSensorValidation = z.infer<typeof DetectionSensorValidationSchema>;
@@ -0,0 +1,21 @@
import { z } from "zod/v4";
export const ExternalNotificationValidationSchema = z.object({
enabled: z.boolean(),
outputMs: z.coerce.number().int().min(0),
output: z.coerce.number().int().min(0),
outputVibra: z.coerce.number().int().min(0),
outputBuzzer: z.coerce.number().int().min(0),
active: z.boolean(),
alertMessage: z.boolean(),
alertMessageVibra: z.boolean(),
alertMessageBuzzer: z.boolean(),
alertBell: z.boolean(),
alertBellVibra: z.boolean(),
alertBellBuzzer: z.boolean(),
usePwm: z.boolean(),
nagTimeout: z.coerce.number().int().min(0),
useI2sAsBuzzer: z.boolean(),
});
export type ExternalNotificationValidation = z.infer<typeof ExternalNotificationValidationSchema>;
@@ -0,0 +1,22 @@
import { z } from "zod/v4";
export const MqttValidationMapReportSettingsSchema = z.object({
publishIntervalSecs: z.coerce.number().int(),
positionPrecision: z.coerce.number().int(),
});
export const MqttValidationSchema = z.object({
enabled: z.boolean(),
address: z.string().min(0).max(30),
username: z.string().min(0).max(30),
password: z.string().min(0).max(30),
encryptionEnabled: z.boolean(),
jsonEnabled: z.boolean(),
tlsEnabled: z.boolean(),
root: z.string(),
proxyToClientEnabled: z.boolean(),
mapReportingEnabled: z.boolean(),
mapReportSettings: MqttValidationMapReportSettingsSchema,
});
export type MqttValidation = z.infer<typeof MqttValidationSchema>;
@@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const NeighborInfoValidationSchema = z.object({
enabled: z.boolean(),
updateInterval: z.coerce.number().int().min(0),
});
export type NeighborInfoValidation = z.infer<typeof NeighborInfoValidationSchema>;
@@ -0,0 +1,10 @@
import { z } from "zod/v4";
export const PaxcounterValidationSchema = z.object({
enabled: z.boolean(),
paxcounterUpdateInterval: z.coerce.number().int().min(0),
bleThreshold: z.coerce.number().int(),
wifiThreshold: z.coerce.number().int(),
});
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>;
@@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const RangeTestValidationSchema = z.object({
enabled: z.boolean(),
sender: z.coerce.number().int().min(0),
save: z.boolean(),
});
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>;
@@ -0,0 +1,11 @@
import { z } from "zod/v4";
export const RemoteHardwareValidationSchema = z.object({
enabled: z.boolean(),
allowUndefinedPinAccess: z.boolean(),
// Preserved untouched: the form does not yet edit the available-pins list,
// so round-trip it to avoid clearing pins configured elsewhere.
availablePins: z.array(z.any()),
});
export type RemoteHardwareValidation = z.infer<typeof RemoteHardwareValidationSchema>;
@@ -0,0 +1,18 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const Serial_BaudEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud);
const Serial_ModeEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode);
export const SerialValidationSchema = z.object({
enabled: z.boolean(),
echo: z.boolean(),
rxd: z.coerce.number().int().min(0),
txd: z.coerce.number().int().min(0),
baud: Serial_BaudEnum,
timeout: z.coerce.number().int().min(0),
mode: Serial_ModeEnum,
overrideConsoleSerialPort: z.boolean(),
});
export type SerialValidation = z.infer<typeof SerialValidationSchema>;
@@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const StatusMessageValidationSchema = z.object({
nodeStatus: z.string().max(200),
});
export type StatusMessageValidation = z.infer<typeof StatusMessageValidationSchema>;
@@ -0,0 +1,11 @@
import { z } from "zod/v4";
export const StoreForwardValidationSchema = z.object({
enabled: z.boolean(),
heartbeat: z.boolean(),
records: z.coerce.number().int().min(0),
historyReturnMax: z.coerce.number().int().min(0),
historyReturnWindow: z.coerce.number().int().min(0),
});
export type StoreForwardValidation = z.infer<typeof StoreForwardValidationSchema>;
@@ -0,0 +1,12 @@
import { Protobuf } from "@meshtastic/core";
import { z } from "zod/v4";
const TeamEnum = z.enum(Protobuf.ATAK.Team);
const MemberRoleEnum = z.enum(Protobuf.ATAK.MemberRole);
export const TakValidationSchema = z.object({
team: TeamEnum,
role: MemberRoleEnum,
});
export type TakValidation = z.infer<typeof TakValidationSchema>;
@@ -0,0 +1,17 @@
import { z } from "zod/v4";
export const TelemetryValidationSchema = z.object({
deviceUpdateInterval: z.coerce.number().int().min(0),
environmentUpdateInterval: z.coerce.number().int().min(0),
environmentMeasurementEnabled: z.boolean(),
environmentScreenEnabled: z.boolean(),
environmentDisplayFahrenheit: z.boolean(),
airQualityEnabled: z.boolean(),
airQualityInterval: z.coerce.number().int().min(0),
powerMeasurementEnabled: z.boolean(),
powerUpdateInterval: z.coerce.number().int().min(0),
powerScreenEnabled: z.boolean(),
airQualityScreenEnabled: z.boolean(),
});
export type TelemetryValidation = z.infer<typeof TelemetryValidationSchema>;
@@ -0,0 +1,20 @@
import { z } from "zod/v4";
export const TrafficManagementValidationSchema = z.object({
enabled: z.boolean(),
positionDedupEnabled: z.boolean(),
positionPrecisionBits: z.coerce.number().int().min(0).max(32),
positionMinIntervalSecs: z.coerce.number().int().min(0),
nodeinfoDirectResponse: z.boolean(),
nodeinfoDirectResponseMaxHops: z.coerce.number().int().min(0).max(7),
rateLimitEnabled: z.boolean(),
rateLimitWindowSecs: z.coerce.number().int().min(0),
rateLimitMaxPackets: z.coerce.number().int().min(0),
dropUnknownEnabled: z.boolean(),
unknownPacketThreshold: z.coerce.number().int().min(0),
exhaustHopTelemetry: z.boolean(),
exhaustHopPosition: z.boolean(),
routerPreserveHops: z.boolean(),
});
export type TrafficManagementValidation = z.infer<typeof TrafficManagementValidationSchema>;
+174
View File
@@ -0,0 +1,174 @@
import { fromByteArray } from "base64-js";
import { describe, expect, it } from "vitest";
import { makePskHelpers } from "./pskSchema.ts";
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("stringSchema", () => {
it("accepts valid base64 string of allowed length", () => {
const { stringSchema } = makePskHelpers([16]);
const valid = makeBase64OfLength(16);
expect(() => stringSchema().parse(valid)).not.toThrow();
});
it("rejects base64 string of disallowed length", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const invalid = makeBase64OfLength(8);
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
it("rejects invalid base64 string", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const result = stringSchema().safeParse("not_base64!");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
}
});
it("rejects empty string if not optional and 0 not allowed", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const result = stringSchema().safeParse("");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
}
});
it("accepts empty string if 0 is allowed", () => {
const { stringSchema } = makePskHelpers([0]);
const result = stringSchema().safeParse("");
expect(result.success).toBe(true);
});
it("accepts empty string if optional=true", () => {
const { stringSchema } = makePskHelpers([16]);
const result = stringSchema(true).safeParse("");
expect(result.success).toBe(true);
});
it("accepts all allowed lengths", () => {
const { stringSchema } = makePskHelpers([8, 16, 32]);
for (const len of [8, 16, 32]) {
const valid = makeBase64OfLength(len);
const result = stringSchema().safeParse(valid);
expect(result.success).toBe(true);
}
});
it("accepts valid base64 string as optional when optional=true", () => {
const { stringSchema } = makePskHelpers([16]);
const valid = makeBase64OfLength(16);
const result = stringSchema(true).safeParse(valid);
expect(result.success).toBe(true);
});
it("rejects base64 string with correct length but extra padding", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const valid = `${makeBase64OfLength(16)}==`;
const result = stringSchema().safeParse(valid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
}
});
it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => {
const { stringSchema } = makePskHelpers([0, 16]);
const result = stringSchema(false).safeParse("");
expect(result.success).toBe(true);
});
it("rejects base64 string with valid format but not in allowedByteLengths", () => {
const { stringSchema, msgs } = makePskHelpers([8, 32]);
const invalid = makeBase64OfLength(16);
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
describe("bytesSchema", () => {
it("accepts valid byte array of allowed length", () => {
const { bytesSchema } = makePskHelpers([16]);
const valid = new Uint8Array(16);
expect(() => bytesSchema().parse(valid)).not.toThrow();
});
it("rejects byte array of disallowed length", () => {
const { bytesSchema, msgs } = makePskHelpers([16]);
const invalid = new Uint8Array(8);
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
it("rejects non-Uint8Array input", () => {
const { bytesSchema } = makePskHelpers([16]);
const result = bytesSchema().safeParse([1, 2, 3]);
expect(result.success).toBe(false);
});
it("rejects empty array if not optional and 0 not allowed", () => {
const { bytesSchema, msgs } = makePskHelpers([16]);
const result = bytesSchema().safeParse(new Uint8Array(0));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
}
});
it("accepts empty array if 0 is allowed", () => {
const { bytesSchema } = makePskHelpers([0]);
const result = bytesSchema().safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("accepts empty array if optional=true", () => {
const { bytesSchema } = makePskHelpers([16]);
const result = bytesSchema(true).safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("accepts all allowed lengths", () => {
const { bytesSchema } = makePskHelpers([8, 16, 32]);
for (const len of [8, 16, 32]) {
const valid = new Uint8Array(len);
const result = bytesSchema().safeParse(valid);
expect(result.success).toBe(true);
}
});
it("accepts valid byte array as optional when optional=true", () => {
const { bytesSchema } = makePskHelpers([16]);
const valid = new Uint8Array(16);
const result = bytesSchema(true).safeParse(valid);
expect(result.success).toBe(true);
});
it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => {
const { bytesSchema } = makePskHelpers([0, 16]);
const result = bytesSchema(false).safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("rejects byte array with valid format but not in allowedByteLengths", () => {
const { bytesSchema, msgs } = makePskHelpers([8, 32]);
const invalid = new Uint8Array(16);
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import { toByteArray } from "base64-js";
import { type ZodType, z } from "zod/v4";
export function makePskHelpers(allowedByteLengths: readonly number[]) {
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | ");
const msgs = {
format: "formValidation.invalidFormat.key",
required: "formValidation.required.key",
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`,
} as const;
function tryParse(str: string): Uint8Array | null {
try {
return toByteArray(str);
} catch {
return null;
}
}
function isValidString(str: string): boolean {
const arr = tryParse(str);
return arr !== null && allowedByteLengths.includes(arr.byteLength);
}
function isValidKey(v: unknown): boolean {
if (typeof v === "string") {
return isValidString(v);
}
if (v instanceof Uint8Array) {
return allowedByteLengths.includes(v.byteLength);
}
return false;
}
const stringSchema = (optional = false) =>
z
.string()
.refine((s) => optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), {
message: msgs.required,
})
.refine((s) => s === "" || tryParse(s) !== null, { message: msgs.format })
.refine((s) => s === "" || isValidString(s), {
message: msgs.length,
params: { bits: bitsLabel },
});
const bytesSchema = (optional = false): ZodType<Uint8Array> =>
z
.instanceof(Uint8Array)
.refine((arr) => optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), {
message: msgs.required,
})
.refine((arr) => optional || allowedByteLengths.includes(arr.byteLength), {
message: msgs.length,
params: { bits: bitsLabel },
});
return {
allowedByteLengths,
msgs,
tryParseStringKey: tryParse,
isValidKey,
stringSchema,
bytesSchema,
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { ZodError, ZodType } from "zod/v4";
export function validateSchema<T>(
schema: ZodType<T>,
data: unknown,
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, errors: result.error.issues };
}