UNPKG

homebridge-philips-hue-sync-box

Version:
265 lines (240 loc) 7.95 kB
import { Execution, Hue, State } from '../state.js'; import { MODE_VIDEO, MODE_MUSIC, MODE_GAME, PASSTHROUGH, POWER_SAVE, BRIGHTNESS_MIN, BRIGHTNESS_MAX_SYNCBOX, HDMI_INPUT_MIN, HDMI_INPUT_MAX, VALID_INTENSITIES, } from './constants.js'; export type ValidationResult<T> = { ok: true; value: Partial<T> } | { ok: false; error: string }; const VALID_EXECUTION_MODES: string[] = [ MODE_VIDEO, MODE_MUSIC, MODE_GAME, PASSTHROUGH, POWER_SAVE, ]; const HDMI_SOURCE_PATTERN = new RegExp( `^input[${HDMI_INPUT_MIN}-${HDMI_INPUT_MAX}]$` ); export function isPlainObject( value: unknown ): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value); } // Matches BaseTvDevice.getInputService()'s `if (!name) throw` guard, so a // blank name fails validation instead of throwing during accessory setup. function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } function isValidHdmiInput(value: unknown): boolean { return isPlainObject(value) && isNonEmptyString(value.name); } // BaseTvDevice's remote up/down handlers do arithmetic on this value and // serialize it back to the Sync Box, and updateLightbulb() forwards it to // the HomeKit Brightness characteristic - a non-finite value turns into // `null`/NaN in both places instead of a TypeError, so it's a correctness // bound rather than a crash guard, but still worth rejecting up front. function isValidBrightness(value: unknown): boolean { return ( typeof value === 'number' && Number.isFinite(value) && value >= BRIGHTNESS_MIN && value <= BRIGHTNESS_MAX_SYNCBOX ); } /** * TvDevice.createInputServices() unconditionally dereferences * hdmi.input1 through input4 (state.hdmi[`input${i}`].name) when the TV * accessory is enabled, so every input slot must be present with a name. */ function isValidHdmiState(value: unknown): boolean { if (!isPlainObject(value)) { return false; } for (let i = HDMI_INPUT_MIN; i <= HDMI_INPUT_MAX; i++) { if (!isValidHdmiInput(value[`input${i}`])) { return false; } } return true; } /** * EntertainmentTvDevice.createInputServices()/updateTv() unconditionally * dereference each entry in hue.groups (group.name) when the entertainment * TV accessory is enabled, so every group must be an object with a name. */ function isValidHueState(value: unknown): boolean { if (!isPlainObject(value) || !isPlainObject(value.groups)) { return false; } return Object.values(value.groups).every( group => isPlainObject(group) && isNonEmptyString(group.name) ); } /** * Confirms the shape every accessory unconditionally dereferences * (device.name/firmwareVersion/uniqueId, execution.mode/hdmiSource/ * brightness, hue.groups[*].name, hdmi.input1-4.name) is actually present, * so a malformed or spoofed Sync Box response fails here instead of * throwing a TypeError - or forwarding NaN/empty values to HomeKit - deep * inside an accessory's update(). */ export function isValidState(value: unknown): value is State { if (!isPlainObject(value)) { return false; } const { device, execution, hue, hdmi } = value; return ( isPlainObject(device) && isNonEmptyString(device.name) && typeof device.firmwareVersion === 'string' && typeof device.uniqueId === 'string' && isPlainObject(execution) && typeof execution.mode === 'string' && typeof execution.hdmiSource === 'string' && HDMI_SOURCE_PATTERN.test(execution.hdmiSource) && isValidBrightness(execution.brightness) && isValidHueState(hue) && isValidHdmiState(hdmi) ); } function validateIntensityPayload( value: unknown, field: string ): ValidationResult<{ intensity: string }> { if (!isPlainObject(value)) { return { ok: false, error: `${field} must be an object.` }; } const keys = Object.keys(value); if (keys.length !== 1 || keys[0] !== 'intensity') { return { ok: false, error: `${field} may only contain an 'intensity' field.`, }; } if ( typeof value.intensity !== 'string' || !VALID_INTENSITIES.includes(value.intensity) ) { return { ok: false, error: `${field}.intensity must be one of: ${VALID_INTENSITIES.join(', ')}.`, }; } return { ok: true, value: { intensity: value.intensity } }; } function validateModeField(value: unknown): ValidationResult<Execution> { if (typeof value !== 'string' || !VALID_EXECUTION_MODES.includes(value)) { return { ok: false, error: `execution.mode must be one of: ${VALID_EXECUTION_MODES.join(', ')}.`, }; } return { ok: true, value: { mode: value } }; } function validateBrightnessField(value: unknown): ValidationResult<Execution> { if ( typeof value !== 'number' || !Number.isFinite(value) || value < BRIGHTNESS_MIN || value > BRIGHTNESS_MAX_SYNCBOX ) { return { ok: false, error: `execution.brightness must be a number between ${BRIGHTNESS_MIN} and ${BRIGHTNESS_MAX_SYNCBOX}.`, }; } return { ok: true, value: { brightness: value } }; } function validateHdmiSourceField(value: unknown): ValidationResult<Execution> { if (typeof value !== 'string' || !HDMI_SOURCE_PATTERN.test(value)) { return { ok: false, error: `execution.hdmiSource must match input${HDMI_INPUT_MIN} through input${HDMI_INPUT_MAX}.`, }; } return { ok: true, value: { hdmiSource: value } }; } function validateExecutionField( key: string, value: unknown ): ValidationResult<Execution> { switch (key) { case 'mode': return validateModeField(value); case 'brightness': return validateBrightnessField(value); case 'hdmiSource': return validateHdmiSourceField(value); case 'video': case 'game': case 'music': { const result = validateIntensityPayload(value, `execution.${key}`); return result.ok ? { ok: true, value: { [key]: result.value } } : result; } default: return { ok: false, error: `execution.${key} is not a recognized or settable field.`, }; } } /** * Applies the same bounds/allowlist checks the HomeKit-driven accessory code applies * before calling SyncBoxClient.updateExecution, so the API server can't be used to send * values no legitimate code path would ever produce. */ export function validateExecution(input: unknown): ValidationResult<Execution> { if (!isPlainObject(input)) { return { ok: false, error: 'execution must be an object.' }; } const value: Record<string, unknown> = {}; for (const key of Object.keys(input)) { const result = validateExecutionField(key, input[key]); if (!result.ok) { return result; } Object.assign(value, result.value); } if (Object.keys(value).length === 0) { return { ok: false, error: 'execution must contain at least one recognized field.', }; } return { ok: true, value: value as Partial<Execution> }; } /** * Mirrors EntertainmentTvDevice's group-existence check: only a groupId that exists in * the device's current state may be forwarded to SyncBoxClient.updateHue. */ export function validateHue( input: unknown, state: State | null ): ValidationResult<Hue> { if (!isPlainObject(input)) { return { ok: false, error: 'hue must be an object.' }; } const keys = Object.keys(input); if (keys.length !== 1 || keys[0] !== 'groupId') { return { ok: false, error: 'hue may only contain a groupId field.' }; } const groupId = input.groupId; if (typeof groupId !== 'string' || !groupId) { return { ok: false, error: 'hue.groupId must be a non-empty string.' }; } if (!state?.hue?.groups || !Object.hasOwn(state.hue.groups, groupId)) { return { ok: false, error: 'hue.groupId does not match a known entertainment area.', }; } return { ok: true, value: { groupId } }; }