homebridge-philips-hue-sync-box
Version:
Homebridge plugin for the Philips Hue Sync Box.
265 lines (240 loc) • 7.95 kB
text/typescript
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 } };
}