Zod config validation (#635)
* Zod WIP * Zod form validation * DynamicForm testing * Fix linting * Delete rasterSource.ts --------- Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
df036d3904
commit
1cbd98ec53
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeChannelSchema } from "./channel.ts";
|
||||
import { fromByteArray } from "base64-js";
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+33
-46
@@ -1,51 +1,38 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Length,
|
||||
} from "class-validator";
|
||||
import { makePskHelpers } from "./pskSchema.ts";
|
||||
import { validateMaxByteLength } from "@core/utils/string.ts";
|
||||
|
||||
export class ChannelValidation
|
||||
implements Omit<Protobuf.Channel.Channel, keyof Message | "settings"> {
|
||||
@IsNumber()
|
||||
index: number;
|
||||
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
|
||||
|
||||
settings: Channel_SettingsValidation;
|
||||
const moduleSettingsSchema = z.object({
|
||||
positionPrecision: z.union([
|
||||
z.literal(0),
|
||||
z.coerce.number().int().min(10).max(19),
|
||||
z.literal(32),
|
||||
]),
|
||||
});
|
||||
|
||||
@IsEnum(Protobuf.Channel.Channel_Role)
|
||||
role: Protobuf.Channel.Channel_Role;
|
||||
}
|
||||
|
||||
export class Channel_SettingsValidation
|
||||
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> {
|
||||
@IsNumber()
|
||||
channelNum: number;
|
||||
|
||||
@IsString()
|
||||
psk: string;
|
||||
|
||||
@Length(0, 11)
|
||||
name: string;
|
||||
|
||||
@IsInt()
|
||||
id: number;
|
||||
|
||||
@IsBoolean()
|
||||
uplinkEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
downlinkEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
positionEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
preciseLocation: boolean;
|
||||
|
||||
@IsInt()
|
||||
positionPrecision: number;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class BluetoothValidation implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_BluetoothConfig,
|
||||
keyof Message | "deviceLoggingEnabled"
|
||||
> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
const PairingModeEnum = z.enum(
|
||||
Protobuf.Config.Config_BluetoothConfig_PairingMode,
|
||||
);
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode)
|
||||
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode;
|
||||
export const BluetoothValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
mode: PairingModeEnum,
|
||||
fixedPin: z.coerce.number().int().min(100000).max(999999),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
fixedPin: number;
|
||||
}
|
||||
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>;
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class DeviceValidation
|
||||
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> {
|
||||
@IsEnum(Protobuf.Config.Config_DeviceConfig_Role)
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role;
|
||||
const RoleEnum = z.enum(
|
||||
Protobuf.Config.Config_DeviceConfig_Role,
|
||||
);
|
||||
const RebroadcastModeEnum = z.enum(
|
||||
Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
|
||||
);
|
||||
|
||||
@IsBoolean()
|
||||
serialEnabled: boolean;
|
||||
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),
|
||||
});
|
||||
|
||||
@IsBoolean()
|
||||
debugLogEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
buttonGpio: number;
|
||||
|
||||
@IsInt()
|
||||
buzzerGpio: number;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode)
|
||||
rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode;
|
||||
|
||||
@IsInt()
|
||||
nodeInfoBroadcastSecs: number;
|
||||
|
||||
@IsBoolean()
|
||||
doubleTapAsButtonPress: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isManaged: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
disableTripleClick: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
ledHeartbeatDisabled: boolean;
|
||||
|
||||
@IsString()
|
||||
tzdef: string;
|
||||
}
|
||||
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>;
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class DisplayValidation
|
||||
implements Omit<Protobuf.Config.Config_DisplayConfig, keyof Message> {
|
||||
@IsInt()
|
||||
screenOnSecs: number;
|
||||
const GpsCoordinateEnum = z.enum(
|
||||
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat)
|
||||
gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
autoScreenCarouselSecs: number;
|
||||
|
||||
@IsBoolean()
|
||||
compassNorthTop: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
flipScreen: boolean;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits)
|
||||
units: Protobuf.Config.Config_DisplayConfig_DisplayUnits;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_OledType)
|
||||
oled: Protobuf.Config.Config_DisplayConfig_OledType;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode)
|
||||
displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode;
|
||||
|
||||
@IsBoolean()
|
||||
headingBold: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
wakeOnTapOrMotion: boolean;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation)
|
||||
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation;
|
||||
}
|
||||
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>;
|
||||
|
||||
@@ -1,65 +1,31 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
|
||||
|
||||
export class LoRaValidation
|
||||
implements
|
||||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> {
|
||||
@IsBoolean()
|
||||
usePreset: boolean;
|
||||
const ModemPresetEnum = z.enum(
|
||||
Protobuf.Config.Config_LoRaConfig_ModemPreset,
|
||||
);
|
||||
const RegionCodeEnum = z.enum(
|
||||
Protobuf.Config.Config_LoRaConfig_RegionCode,
|
||||
);
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset)
|
||||
modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset;
|
||||
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().int(),
|
||||
ignoreIncoming: z.coerce.number().array(),
|
||||
ignoreMqtt: z.boolean(),
|
||||
configOkToMqtt: z.boolean(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
bandwidth: number;
|
||||
|
||||
@IsInt()
|
||||
// @Min(7)
|
||||
@Max(12)
|
||||
spreadFactor: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
codingRate: number;
|
||||
|
||||
@IsInt()
|
||||
frequencyOffset: number;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode)
|
||||
region: Protobuf.Config.Config_LoRaConfig_RegionCode;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(7)
|
||||
hopLimit: number;
|
||||
|
||||
@IsBoolean()
|
||||
txEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
txPower: number;
|
||||
|
||||
@IsInt()
|
||||
channelNum: number;
|
||||
|
||||
@IsBoolean()
|
||||
overrideDutyCycle: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
sx126xRxBoostedGain: boolean;
|
||||
|
||||
@IsInt()
|
||||
overrideFrequency: number;
|
||||
|
||||
@IsArray()
|
||||
ignoreIncoming: number[];
|
||||
|
||||
@IsBoolean()
|
||||
ignoreMqtt: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
configOkToMqtt: boolean;
|
||||
}
|
||||
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
const AddressModeEnum = z.nativeEnum(
|
||||
const AddressModeEnum = z.enum(
|
||||
Protobuf.Config.Config_NetworkConfig_AddressMode,
|
||||
);
|
||||
const ProtocolFlagsEnum = z.nativeEnum(
|
||||
const ProtocolFlagsEnum = z.enum(
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
);
|
||||
|
||||
export const NetworkValidationIpV4ConfigSchema = z.object({
|
||||
ip: z.string().ip(),
|
||||
gateway: z.string().ip(),
|
||||
subnet: z.string().ip(),
|
||||
dns: z.string().ip(),
|
||||
ip: z.ipv4(),
|
||||
gateway: z.ipv4(),
|
||||
subnet: z.ipv4(),
|
||||
dns: z.ipv4(),
|
||||
});
|
||||
|
||||
export const NetworkValidationSchema = z.object({
|
||||
wifiEnabled: z.boolean(),
|
||||
wifiSsid: z.string().min(0).max(33).optional(),
|
||||
wifiPsk: z.string().min(0).max(64).optional(),
|
||||
ntpServer: z.string().min(2).max(30),
|
||||
wifiSsid: z.string().max(33),
|
||||
wifiPsk: z.string().max(64),
|
||||
ntpServer: z.string().min(2).max(33),
|
||||
ethEnabled: z.boolean(),
|
||||
addressMode: AddressModeEnum,
|
||||
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(),
|
||||
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
||||
enabledProtocols: ProtocolFlagsEnum,
|
||||
rsyslogServer: z.string(),
|
||||
rsyslogServer: z.string().max(33),
|
||||
});
|
||||
|
||||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
|
||||
const GpsModeEnum = z.enum(
|
||||
Protobuf.Config.Config_PositionConfig_GpsMode,
|
||||
);
|
||||
|
||||
export class PositionValidation implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_PositionConfig,
|
||||
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
|
||||
> {
|
||||
@IsInt()
|
||||
positionBroadcastSecs: number;
|
||||
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,
|
||||
});
|
||||
|
||||
@IsBoolean()
|
||||
positionBroadcastSmartEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
fixedPosition: boolean;
|
||||
|
||||
@IsInt()
|
||||
gpsUpdateInterval: number;
|
||||
|
||||
@IsInt()
|
||||
positionFlags: number;
|
||||
|
||||
@IsInt()
|
||||
rxGpio: number;
|
||||
|
||||
@IsInt()
|
||||
txGpio: number;
|
||||
|
||||
@IsInt()
|
||||
broadcastSmartMinimumDistance: number;
|
||||
|
||||
@IsInt()
|
||||
broadcastSmartMinimumIntervalSecs: number;
|
||||
|
||||
@IsInt()
|
||||
gpsEnGpio: number;
|
||||
|
||||
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode)
|
||||
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode;
|
||||
}
|
||||
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class PowerValidation implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_PowerConfig,
|
||||
keyof Message | "powermonEnables"
|
||||
> {
|
||||
@IsBoolean()
|
||||
isPowerSaving: boolean;
|
||||
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),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
onBatteryShutdownAfterSecs: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(4)
|
||||
adcMultiplierOverride: number;
|
||||
|
||||
@IsInt()
|
||||
waitBluetoothSecs: number;
|
||||
|
||||
@IsInt()
|
||||
sdsSecs: number;
|
||||
|
||||
@IsInt()
|
||||
lsSecs: number;
|
||||
|
||||
@IsInt()
|
||||
minWakeSecs: number;
|
||||
|
||||
@IsInt()
|
||||
deviceBatteryInaAddress: number;
|
||||
}
|
||||
export type PowerValidation = z.infer<typeof PowerValidationSchema>;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fromByteArray } from "base64-js";
|
||||
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.adminKeyRequiredWhenManaged"
|
||||
),
|
||||
).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()],
|
||||
});
|
||||
console.log(result);
|
||||
|
||||
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.adminKeyRequiredWhenManaged"
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,47 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsString } from "class-validator";
|
||||
import { z, ZodType } from "zod/v4";
|
||||
import { makePskHelpers } from "./../pskSchema.ts";
|
||||
|
||||
export class SecurityValidation implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_SecurityConfig,
|
||||
| keyof Message
|
||||
| "adminKey"
|
||||
| "privateKey"
|
||||
| "publicKey"
|
||||
> {
|
||||
@IsBoolean()
|
||||
adminChannelEnabled: boolean;
|
||||
const {
|
||||
stringSchema,
|
||||
bytesSchema,
|
||||
isValidKey,
|
||||
} = makePskHelpers([32]); // 256-bit
|
||||
|
||||
@IsString()
|
||||
adminKey: [string, string, string];
|
||||
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
|
||||
|
||||
@IsBoolean()
|
||||
bluetoothLoggingEnabled: boolean;
|
||||
function makeSecuritySchema<KeyT>(
|
||||
keyMaker: (optional: boolean) => ZodType<KeyT>,
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
isManaged: z.boolean(),
|
||||
adminChannelEnabled: z.boolean(),
|
||||
debugLogApiEnabled: z.boolean(),
|
||||
serialEnabled: z.boolean(),
|
||||
|
||||
@IsBoolean()
|
||||
debugLogApiEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isManaged: boolean;
|
||||
|
||||
@IsString()
|
||||
privateKey: string;
|
||||
|
||||
@IsString()
|
||||
publicKey: string;
|
||||
|
||||
@IsBoolean()
|
||||
serialEnabled: 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>;
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class AmbientLightingValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig,
|
||||
keyof Message
|
||||
> {
|
||||
@IsBoolean()
|
||||
ledState: boolean;
|
||||
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),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
current: number;
|
||||
|
||||
@IsInt()
|
||||
red: number;
|
||||
|
||||
@IsInt()
|
||||
green: number;
|
||||
|
||||
@IsInt()
|
||||
blue: number;
|
||||
}
|
||||
export type AmbientLightingValidation = z.infer<
|
||||
typeof AmbientLightingValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class AudioValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> {
|
||||
@IsBoolean()
|
||||
codec2Enabled: boolean;
|
||||
const Audio_BaudEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
|
||||
);
|
||||
|
||||
@IsInt()
|
||||
pttPin: number;
|
||||
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),
|
||||
});
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud)
|
||||
bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud;
|
||||
|
||||
@IsInt()
|
||||
i2sWs: number;
|
||||
|
||||
@IsInt()
|
||||
i2sSd: number;
|
||||
|
||||
@IsInt()
|
||||
i2sDin: number;
|
||||
|
||||
@IsInt()
|
||||
i2sSck: number;
|
||||
}
|
||||
export type AudioValidation = z.infer<typeof AudioValidationSchema>;
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt, Length } from "class-validator";
|
||||
|
||||
export class CannedMessageValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig,
|
||||
keyof Message
|
||||
> {
|
||||
@IsBoolean()
|
||||
rotary1Enabled: boolean;
|
||||
const InputEventCharEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
);
|
||||
|
||||
@IsInt()
|
||||
inputbrokerPinA: number;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
inputbrokerPinB: number;
|
||||
|
||||
@IsInt()
|
||||
inputbrokerPinPress: number;
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
|
||||
inputbrokerEventCw:
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
|
||||
inputbrokerEventCcw:
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
|
||||
inputbrokerEventPress:
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
|
||||
|
||||
@IsBoolean()
|
||||
updown1Enabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@Length(2, 30)
|
||||
allowInputSource: string;
|
||||
|
||||
@IsBoolean()
|
||||
sendBell: boolean;
|
||||
}
|
||||
export type CannedMessageValidation = z.infer<
|
||||
typeof CannedMessageValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt, Length } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
export class DetectionSensorValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig,
|
||||
keyof Message
|
||||
> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
const detectionTriggerTypeEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||
);
|
||||
|
||||
@IsInt()
|
||||
minimumBroadcastSecs: number;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
stateBroadcastSecs: number;
|
||||
|
||||
@IsBoolean()
|
||||
sendBell: boolean;
|
||||
|
||||
@Length(0, 20)
|
||||
name: string;
|
||||
|
||||
@IsInt()
|
||||
monitorPin: number;
|
||||
|
||||
@IsBoolean()
|
||||
detectionTriggeredHigh: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
usePullup: boolean;
|
||||
}
|
||||
export type DetectionSensorValidation = z.infer<
|
||||
typeof DetectionSensorValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class ExternalNotificationValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig,
|
||||
keyof Message
|
||||
> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
outputMs: number;
|
||||
|
||||
@IsInt()
|
||||
output: number;
|
||||
|
||||
@IsInt()
|
||||
outputVibra: number;
|
||||
|
||||
@IsInt()
|
||||
outputBuzzer: number;
|
||||
|
||||
@IsBoolean()
|
||||
active: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertMessage: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertMessageVibra: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertMessageBuzzer: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertBell: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertBellVibra: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
alertBellBuzzer: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
usePwm: boolean;
|
||||
|
||||
@IsInt()
|
||||
nagTimeout: number;
|
||||
|
||||
@IsBoolean()
|
||||
useI2sAsBuzzer: boolean;
|
||||
}
|
||||
export type ExternalNotificationValidation = z.infer<
|
||||
typeof ExternalNotificationValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,59 +1,22 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
} from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class MqttValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_MQTTConfig,
|
||||
keyof Message | "mapReportSettings"
|
||||
> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
export const MqttValidationMapReportSettingsSchema = z.object({
|
||||
publishIntervalSecs: z.number().optional(),
|
||||
positionPrecision: z.number().optional(),
|
||||
});
|
||||
|
||||
@Length(0, 30)
|
||||
address: string;
|
||||
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,
|
||||
});
|
||||
|
||||
@Length(0, 30)
|
||||
username: string;
|
||||
|
||||
@Length(0, 30)
|
||||
password: string;
|
||||
|
||||
@IsBoolean()
|
||||
encryptionEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
jsonEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
tlsEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
root: string;
|
||||
|
||||
@IsBoolean()
|
||||
proxyToClientEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
mapReportingEnabled: boolean;
|
||||
|
||||
mapReportSettings: MqttValidationMapReportSettings;
|
||||
}
|
||||
|
||||
export class MqttValidationMapReportSettings
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
publishIntervalSecs: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
positionPrecision: number;
|
||||
}
|
||||
export type MqttValidation = z.infer<typeof MqttValidationSchema>;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class NeighborInfoValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
export const NeighborInfoValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
updateInterval: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
updateInterval: number;
|
||||
}
|
||||
export type NeighborInfoValidation = z.infer<
|
||||
typeof NeighborInfoValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class PaxcounterValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
paxcounterUpdateInterval: number;
|
||||
|
||||
@IsInt()
|
||||
bleThreshold: number;
|
||||
|
||||
@IsInt()
|
||||
wifiThreshold: number;
|
||||
}
|
||||
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>;
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class RangeTestValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
export const RangeTestValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
sender: z.coerce.number().int().min(0),
|
||||
save: z.boolean(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
sender: number;
|
||||
|
||||
@IsBoolean()
|
||||
save: boolean;
|
||||
}
|
||||
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>;
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { z } from "zod/v4";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class SerialValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
const Serial_BaudEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
|
||||
);
|
||||
const Serial_ModeEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
|
||||
);
|
||||
|
||||
@IsBoolean()
|
||||
echo: boolean;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
rxd: number;
|
||||
|
||||
@IsInt()
|
||||
txd: number;
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud)
|
||||
baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud;
|
||||
|
||||
@IsInt()
|
||||
timeout: number;
|
||||
|
||||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode)
|
||||
mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode;
|
||||
|
||||
@IsBoolean()
|
||||
overrideConsoleSerialPort: boolean;
|
||||
}
|
||||
export type SerialValidation = z.infer<typeof SerialValidationSchema>;
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class StoreForwardValidation implements
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
|
||||
keyof Message | "isServer"
|
||||
> {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
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),
|
||||
});
|
||||
|
||||
@IsBoolean()
|
||||
heartbeat: boolean;
|
||||
|
||||
@IsInt()
|
||||
records: number;
|
||||
|
||||
@IsInt()
|
||||
historyReturnMax: number;
|
||||
|
||||
@IsInt()
|
||||
historyReturnWindow: number;
|
||||
}
|
||||
export type StoreForwardValidation = z.infer<
|
||||
typeof StoreForwardValidationSchema
|
||||
>;
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class TelemetryValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> {
|
||||
@IsInt()
|
||||
deviceUpdateInterval: number;
|
||||
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(),
|
||||
});
|
||||
|
||||
@IsInt()
|
||||
environmentUpdateInterval: number;
|
||||
|
||||
@IsBoolean()
|
||||
environmentMeasurementEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
environmentScreenEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
environmentDisplayFahrenheit: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
airQualityEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
airQualityInterval: number;
|
||||
|
||||
@IsBoolean()
|
||||
powerMeasurementEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
powerUpdateInterval: number;
|
||||
|
||||
@IsBoolean()
|
||||
powerScreenEnabled: boolean;
|
||||
}
|
||||
export type TelemetryValidation = z.infer<
|
||||
typeof TelemetryValidationSchema
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makePskHelpers } from "./pskSchema.ts";
|
||||
import { fromByteArray } from "base64-js";
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z, ZodType } from "zod/v4";
|
||||
import { toByteArray } from "base64-js";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator";
|
||||
|
||||
import type { RasterSource } from "@core/stores/appStore.ts";
|
||||
|
||||
export class MapValidation {
|
||||
@IsArray()
|
||||
rasterSources: MapValidation_RasterSources[];
|
||||
}
|
||||
|
||||
export class MapValidation_RasterSources implements RasterSource {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsUrl()
|
||||
tiles: string;
|
||||
|
||||
@IsNumber()
|
||||
tileSize: number;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ZodError, ZodSchema } from "zod";
|
||||
import { ZodError, ZodType } from "zod/v4";
|
||||
|
||||
export function validateSchema<T>(
|
||||
schema: ZodSchema<T>,
|
||||
schema: ZodType<T>,
|
||||
data: unknown,
|
||||
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
Reference in New Issue
Block a user