import Schema from 'async-validator'; import type { InternalRuleItem } from 'async-validator'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; import type { AnalogSensor, DeviceValue, EntityItem, ScheduleItem, Settings, TemperatureSensor } from './types'; // Constants const ERROR_MESSAGES = { GPIO_INVALID: 'Must be an valid GPIO port', NAME_DUPLICATE: 'Name already in use', GPIO_DUPLICATE: 'GPIO already in use', VALUE_OUT_OF_RANGE: 'Value out of range', HEX_REQUIRED: 'Is required and must be in hex format' } as const; const VALIDATION_LIMITS = { PORT_MIN: 0, PORT_MAX: 65535, MODBUS_MAX_CLIENTS_MIN: 0, MODBUS_MAX_CLIENTS_MAX: 50, MODBUS_TIMEOUT_MIN: 100, MODBUS_TIMEOUT_MAX: 20000, SYSLOG_MARK_INTERVAL_MIN: 0, SYSLOG_MARK_INTERVAL_MAX: 10, SHOWER_MIN_DURATION_MIN: 10, SHOWER_MIN_DURATION_MAX: 360, SHOWER_ALERT_TRIGGER_MIN: 1, SHOWER_ALERT_TRIGGER_MAX: 20, SHOWER_ALERT_COLDSHOT_MIN: 1, SHOWER_ALERT_COLDSHOT_MAX: 10, REMOTE_TIMEOUT_MIN: 1, REMOTE_TIMEOUT_MAX: 240, OFFSET_MIN: 0, OFFSET_MAX: 255, COMMAND_MIN: 1, COMMAND_MAX: 300, NAME_MAX_LENGTH: 19, HEX_BASE: 16 } as const; // Helper to create GPIO validator from invalid ranges const createGPIOValidator = ( invalidRanges: Array, maxValue: number ) => ({ validator( _rule: InternalRuleItem, value: number, callback: (error?: string) => void ) { if (!value) { callback(); return; } if (value < 0 || value > maxValue) { callback(ERROR_MESSAGES.GPIO_INVALID); return; } for (const range of invalidRanges) { if (typeof range === 'number') { if (value === range) { callback(ERROR_MESSAGES.GPIO_INVALID); return; } } else { const [start, end] = range; if (value >= start && value <= end) { callback(ERROR_MESSAGES.GPIO_INVALID); return; } } } callback(); } }); export const GPIO_VALIDATOR = createGPIOValidator( [[6, 11], 1, 20, 24, [28, 31]], 40 ); export const GPIO_VALIDATORC3 = createGPIOValidator([[11, 19]], 21); export const GPIO_VALIDATORS2 = createGPIOValidator( [ [19, 20], [22, 32] ], 40 ); export const GPIO_VALIDATORS3 = createGPIOValidator( [ [19, 20], [22, 37], [39, 42] ], 48 ); const GPIO_FIELD_NAMES = [ 'led_gpio', 'dallas_gpio', 'pbutton_gpio', 'tx_gpio', 'rx_gpio' ] as const; type ValidationRules = Array<{ required?: boolean; message?: string; [key: string]: unknown; }>; const createGPIOValidations = ( validator: typeof GPIO_VALIDATOR ): Record => GPIO_FIELD_NAMES.reduce( (acc, field) => { const fieldName = field.replace('_gpio', '').toUpperCase(); acc[field] = [ { required: true, message: `${fieldName} GPIO is required` }, validator ]; return acc; }, {} as Record ); const PLATFORM_VALIDATORS = { ESP32: GPIO_VALIDATOR, ESP32C3: GPIO_VALIDATORC3, ESP32S2: GPIO_VALIDATORS2, ESP32S3: GPIO_VALIDATORS3 } as const; export const createSettingsValidator = (settings: Settings) => { const schema: Record = {}; // Add GPIO validations for CUSTOM board profiles if ( settings.board_profile === 'CUSTOM' && settings.platform in PLATFORM_VALIDATORS ) { Object.assign( schema, createGPIOValidations( PLATFORM_VALIDATORS[settings.platform as keyof typeof PLATFORM_VALIDATORS] ) ); } // Syslog validations if (settings.syslog_enabled) { schema.syslog_host = [ { required: true, message: 'Host is required' }, IP_OR_HOSTNAME_VALIDATOR ]; schema.syslog_port = [ { required: true, message: 'Port is required' }, { type: 'number', min: VALIDATION_LIMITS.PORT_MIN, max: VALIDATION_LIMITS.PORT_MAX, message: 'Invalid Port' } ]; schema.syslog_mark_interval = [ { required: true, message: 'Mark interval is required' }, { type: 'number', min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN, max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX, message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}` } ]; } // Modbus validations if (settings.modbus_enabled) { schema.modbus_max_clients = [ { required: true, message: 'Max clients is required' }, { type: 'number', min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN, max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX, message: 'Invalid number' } ]; schema.modbus_port = [ { required: true, message: 'Port is required' }, { type: 'number', min: VALIDATION_LIMITS.PORT_MIN, max: VALIDATION_LIMITS.PORT_MAX, message: 'Invalid Port' } ]; schema.modbus_timeout = [ { required: true, message: 'Timeout is required' }, { type: 'number', min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN, max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX, message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}` } ]; } // Shower timer validations if (settings.shower_timer) { schema.shower_min_duration = [ { type: 'number', min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN, max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX, message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds` } ]; } // Shower alert validations if (settings.shower_alert) { schema.shower_alert_trigger = [ { type: 'number', min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN, max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX, message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes` } ]; schema.shower_alert_coldshot = [ { type: 'number', min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN, max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX, message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds` } ]; } // Remote timeout validations if (settings.remote_timeout_en) { schema.remote_timeout = [ { type: 'number', min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN, max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX, message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours` } ]; } return new Schema(schema); }; // Generic unique name validator factory const createUniqueNameValidator = ( items: T[], originalName?: string ) => ({ validator( _rule: InternalRuleItem, name: string, callback: (error?: string) => void ) { if ( name !== '' && (originalName === undefined || originalName.toLowerCase() !== name.toLowerCase()) && items.find((item) => item.name.toLowerCase() === name.toLowerCase()) ) { callback(ERROR_MESSAGES.NAME_DUPLICATE); return; } callback(); } }); // Generic field name validator (for cases where the name field has different property names) const createUniqueFieldNameValidator = ( items: T[], getName: (item: T) => string, originalName?: string ) => ({ validator( _rule: InternalRuleItem, name: string, callback: (error?: string) => void ) { if ( name !== '' && (originalName === undefined || originalName.toLowerCase() !== name.toLowerCase()) && items.find((item) => getName(item).toLowerCase() === name.toLowerCase()) ) { callback(ERROR_MESSAGES.NAME_DUPLICATE); return; } callback(); } }); const NAME_PATTERN_BASE = '[a-zA-Z0-9_]'; const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`; const NAME_PATTERN = { type: 'string' as const, pattern: new RegExp( `^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$` ), message: NAME_PATTERN_MESSAGE }; const NAME_PATTERN_REQUIRED = { type: 'string' as const, pattern: new RegExp( `^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$` ), message: NAME_PATTERN_MESSAGE }; export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => createUniqueNameValidator(schedule, o_name); export const schedulerItemValidation = ( schedule: ScheduleItem[], scheduleItem: ScheduleItem ) => new Schema({ name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)], cmd: [ { required: true, message: 'Command is required' }, { type: 'string', min: VALIDATION_LIMITS.COMMAND_MIN, max: VALIDATION_LIMITS.COMMAND_MAX, message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters` } ] }); export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) => createUniqueNameValidator(entity, o_name); const hexValidator = { validator( _rule: InternalRuleItem, value: string, callback: (error?: string) => void ) { if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) { callback(ERROR_MESSAGES.HEX_REQUIRED); return; } callback(); } }; export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) => new Schema({ name: [ { required: true, message: 'Name is required' }, NAME_PATTERN_REQUIRED, uniqueCustomNameValidator(entity, entityItem.o_name) ], device_id: [hexValidator], type_id: [hexValidator], offset: [ { required: true, message: 'Offset is required' }, { type: 'number', min: VALIDATION_LIMITS.OFFSET_MIN, max: VALIDATION_LIMITS.OFFSET_MAX, message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}` } ], factor: [{ required: true, message: 'is required' }] }); export const uniqueTemperatureNameValidator = ( sensors: TemperatureSensor[], o_name?: string ) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name); export const temperatureSensorItemValidation = ( sensors: TemperatureSensor[], sensor: TemperatureSensor ) => new Schema({ n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)] }); export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({ validator( _rule: InternalRuleItem, gpio: number, callback: (error?: string) => void ) { if (sensors.some((as) => as.g === gpio)) { callback(ERROR_MESSAGES.GPIO_DUPLICATE); return; } callback(); } }); export const uniqueAnalogNameValidator = ( sensors: AnalogSensor[], o_name?: string ) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name); const getPlatformGPIOValidator = (platform: string) => { switch (platform) { case 'ESP32S3': return GPIO_VALIDATORS3; case 'ESP32S2': return GPIO_VALIDATORS2; case 'ESP32C3': return GPIO_VALIDATORC3; default: return GPIO_VALIDATOR; } }; export const analogSensorItemValidation = ( sensors: AnalogSensor[], sensor: AnalogSensor, creating: boolean, platform: string ) => { const gpioValidator = getPlatformGPIOValidator(platform); return new Schema({ n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)], g: [ { required: true, message: 'GPIO is required' }, gpioValidator, ...(creating ? [isGPIOUniqueValidator(sensors)] : []) ] }); }; export const deviceValueItemValidation = (dv: DeviceValue) => new Schema({ v: [ { required: true, message: 'Value is required' }, { validator( _rule: InternalRuleItem, value: unknown, callback: (error?: string) => void ) { if ( typeof value === 'number' && dv.m !== undefined && dv.x !== undefined && (value < dv.m || value > dv.x) ) { callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE); return; } callback(); } } ] });