refactor: Migrate Home Assistant schemas from Ajv to Zod validation
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js';
|
import {
|
||||||
import Ajv from 'ajv';
|
validateEntity,
|
||||||
import { describe, expect, test } from "bun:test";
|
validateService,
|
||||||
|
validateStateChangedEvent,
|
||||||
const ajv = new Ajv();
|
validateConfig,
|
||||||
|
validateAutomation,
|
||||||
// Create validation functions for each schema
|
validateDeviceControl
|
||||||
const validateEntity = ajv.compile(entitySchema);
|
} from '../../src/schemas/hass.js';
|
||||||
const validateService = ajv.compile(serviceSchema);
|
|
||||||
|
|
||||||
describe('Home Assistant Schemas', () => {
|
describe('Home Assistant Schemas', () => {
|
||||||
describe('Entity Schema', () => {
|
describe('Entity Schema', () => {
|
||||||
@@ -17,7 +16,7 @@ describe('Home Assistant Schemas', () => {
|
|||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 255,
|
brightness: 255,
|
||||||
friendly_name: 'Living Room Light'
|
color_temp: 300
|
||||||
},
|
},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
last_updated: '2024-01-01T00:00:00Z',
|
||||||
@@ -27,17 +26,17 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(validEntity)).toBe(true);
|
const result = validateEntity(validEntity);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject entity with missing required fields', () => {
|
test('should reject entity with missing required fields', () => {
|
||||||
const invalidEntity = {
|
const invalidEntity = {
|
||||||
entity_id: 'light.living_room',
|
state: 'on',
|
||||||
state: 'on'
|
attributes: {}
|
||||||
// missing attributes, last_changed, last_updated, context
|
|
||||||
};
|
};
|
||||||
expect(validateEntity(invalidEntity)).toBe(false);
|
const result = validateEntity(invalidEntity);
|
||||||
expect(validateEntity.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate entity with additional attributes', () => {
|
test('should validate entity with additional attributes', () => {
|
||||||
@@ -45,8 +44,9 @@ describe('Home Assistant Schemas', () => {
|
|||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 100,
|
brightness: 255,
|
||||||
color_mode: 'brightness'
|
color_temp: 300,
|
||||||
|
custom_attr: 'value'
|
||||||
},
|
},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
last_updated: '2024-01-01T00:00:00Z',
|
||||||
@@ -56,12 +56,13 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(validEntity)).toBe(true);
|
const result = validateEntity(validEntity);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject invalid entity_id format', () => {
|
test('should reject invalid entity_id format', () => {
|
||||||
const invalidEntity = {
|
const invalidEntity = {
|
||||||
entity_id: 'invalid_entity',
|
entity_id: 'invalid_format',
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {},
|
attributes: {},
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
last_changed: '2024-01-01T00:00:00Z',
|
||||||
@@ -72,7 +73,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateEntity(invalidEntity)).toBe(false);
|
const result = validateEntity(invalidEntity);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,13 +84,14 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: ['light.living_room']
|
entity_id: 'light.living_room'
|
||||||
},
|
},
|
||||||
service_data: {
|
service_data: {
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(basicService)).toBe(true);
|
const result = validateService(basicService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate service call with multiple targets', () => {
|
test('should validate service call with multiple targets', () => {
|
||||||
@@ -96,15 +99,14 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: ['light.living_room', 'light.kitchen'],
|
entity_id: ['light.living_room', 'light.kitchen']
|
||||||
device_id: ['device123', 'device456'],
|
|
||||||
area_id: ['living_room', 'kitchen']
|
|
||||||
},
|
},
|
||||||
service_data: {
|
service_data: {
|
||||||
brightness_pct: 100
|
brightness_pct: 100
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(multiTargetService)).toBe(true);
|
const result = validateService(multiTargetService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate service call without targets', () => {
|
test('should validate service call without targets', () => {
|
||||||
@@ -112,7 +114,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'homeassistant',
|
domain: 'homeassistant',
|
||||||
service: 'restart'
|
service: 'restart'
|
||||||
};
|
};
|
||||||
expect(validateService(noTargetService)).toBe(true);
|
const result = validateService(noTargetService);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject service call with invalid target type', () => {
|
test('should reject service call with invalid target type', () => {
|
||||||
@@ -120,57 +123,37 @@ describe('Home Assistant Schemas', () => {
|
|||||||
domain: 'light',
|
domain: 'light',
|
||||||
service: 'turn_on',
|
service: 'turn_on',
|
||||||
target: {
|
target: {
|
||||||
entity_id: 'not_an_array' // should be an array
|
entity_id: 123 // Invalid type
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validateService(invalidService)).toBe(false);
|
const result = validateService(invalidService);
|
||||||
expect(validateService.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject service call with invalid domain', () => {
|
test('should reject service call with invalid domain', () => {
|
||||||
const invalidService = {
|
const invalidService = {
|
||||||
domain: 'invalid_domain',
|
domain: '',
|
||||||
service: 'turn_on',
|
service: 'turn_on'
|
||||||
target: {
|
|
||||||
entity_id: ['light.living_room']
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
expect(validateService(invalidService)).toBe(false);
|
const result = validateService(invalidService);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('State Changed Event Schema', () => {
|
describe('State Changed Event Schema', () => {
|
||||||
const validate = ajv.compile(stateChangedEventSchema);
|
|
||||||
|
|
||||||
test('should validate a valid state changed event', () => {
|
test('should validate a valid state changed event', () => {
|
||||||
const validEvent = {
|
const validEvent = {
|
||||||
event_type: 'state_changed',
|
event_type: 'state_changed',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
|
old_state: {
|
||||||
|
state: 'off',
|
||||||
|
attributes: {}
|
||||||
|
},
|
||||||
new_state: {
|
new_state: {
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {
|
attributes: {
|
||||||
brightness: 255
|
brightness: 255
|
||||||
},
|
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
|
||||||
context: {
|
|
||||||
id: '123456',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
old_state: {
|
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'off',
|
|
||||||
attributes: {},
|
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
|
||||||
context: {
|
|
||||||
id: '123456',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -182,7 +165,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(validEvent)).toBe(true);
|
const result = validateStateChangedEvent(validEvent);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate event with null old_state', () => {
|
test('should validate event with null old_state', () => {
|
||||||
@@ -190,19 +174,11 @@ describe('Home Assistant Schemas', () => {
|
|||||||
event_type: 'state_changed',
|
event_type: 'state_changed',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
|
old_state: null,
|
||||||
new_state: {
|
new_state: {
|
||||||
entity_id: 'light.living_room',
|
|
||||||
state: 'on',
|
state: 'on',
|
||||||
attributes: {},
|
attributes: {}
|
||||||
last_changed: '2024-01-01T00:00:00Z',
|
}
|
||||||
last_updated: '2024-01-01T00:00:00Z',
|
|
||||||
context: {
|
|
||||||
id: '123456',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
old_state: null
|
|
||||||
},
|
},
|
||||||
origin: 'LOCAL',
|
origin: 'LOCAL',
|
||||||
time_fired: '2024-01-01T00:00:00Z',
|
time_fired: '2024-01-01T00:00:00Z',
|
||||||
@@ -212,7 +188,8 @@ describe('Home Assistant Schemas', () => {
|
|||||||
user_id: null
|
user_id: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(newEntityEvent)).toBe(true);
|
const result = validateStateChangedEvent(newEntityEvent);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject event with invalid event_type', () => {
|
test('should reject event with invalid event_type', () => {
|
||||||
@@ -220,278 +197,62 @@ describe('Home Assistant Schemas', () => {
|
|||||||
event_type: 'wrong_type',
|
event_type: 'wrong_type',
|
||||||
data: {
|
data: {
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
new_state: null,
|
old_state: null,
|
||||||
old_state: null
|
new_state: {
|
||||||
},
|
state: 'on',
|
||||||
origin: 'LOCAL',
|
attributes: {}
|
||||||
time_fired: '2024-01-01T00:00:00Z',
|
}
|
||||||
context: {
|
|
||||||
id: '123456',
|
|
||||||
parent_id: null,
|
|
||||||
user_id: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(invalidEvent)).toBe(false);
|
const result = validateStateChangedEvent(invalidEvent);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Config Schema', () => {
|
describe('Config Schema', () => {
|
||||||
const validate = ajv.compile(configSchema);
|
|
||||||
|
|
||||||
test('should validate a minimal config', () => {
|
test('should validate a minimal config', () => {
|
||||||
const minimalConfig = {
|
const minimalConfig = {
|
||||||
latitude: 52.3731,
|
|
||||||
longitude: 4.8922,
|
|
||||||
elevation: 0,
|
|
||||||
unit_system: {
|
|
||||||
length: 'km',
|
|
||||||
mass: 'kg',
|
|
||||||
temperature: '°C',
|
|
||||||
volume: 'L'
|
|
||||||
},
|
|
||||||
location_name: 'Home',
|
location_name: 'Home',
|
||||||
time_zone: 'Europe/Amsterdam',
|
time_zone: 'Europe/Amsterdam',
|
||||||
components: ['homeassistant'],
|
components: ['homeassistant'],
|
||||||
version: '2024.1.0'
|
version: '2024.1.0'
|
||||||
};
|
};
|
||||||
expect(validate(minimalConfig)).toBe(true);
|
const result = validateConfig(minimalConfig);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject config with missing required fields', () => {
|
test('should reject config with missing required fields', () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
latitude: 52.3731,
|
location_name: 'Home'
|
||||||
longitude: 4.8922
|
|
||||||
// missing other required fields
|
|
||||||
};
|
};
|
||||||
expect(validate(invalidConfig)).toBe(false);
|
const result = validateConfig(invalidConfig);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject config with invalid types', () => {
|
test('should reject config with invalid types', () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
latitude: '52.3731', // should be number
|
location_name: 123,
|
||||||
longitude: 4.8922,
|
|
||||||
elevation: 0,
|
|
||||||
unit_system: {
|
|
||||||
length: 'km',
|
|
||||||
mass: 'kg',
|
|
||||||
temperature: '°C',
|
|
||||||
volume: 'L'
|
|
||||||
},
|
|
||||||
location_name: 'Home',
|
|
||||||
time_zone: 'Europe/Amsterdam',
|
time_zone: 'Europe/Amsterdam',
|
||||||
components: ['homeassistant'],
|
components: 'not_an_array',
|
||||||
version: '2024.1.0'
|
version: '2024.1.0'
|
||||||
};
|
};
|
||||||
expect(validate(invalidConfig)).toBe(false);
|
const result = validateConfig(invalidConfig);
|
||||||
expect(validate.errors).toBeDefined();
|
expect(result.success).toBe(false);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Automation Schema', () => {
|
|
||||||
const validate = ajv.compile(automationSchema);
|
|
||||||
|
|
||||||
test('should validate a basic automation', () => {
|
|
||||||
const basicAutomation = {
|
|
||||||
alias: 'Turn on lights at sunset',
|
|
||||||
description: 'Automatically turn on lights when the sun sets',
|
|
||||||
trigger: [{
|
|
||||||
platform: 'sun',
|
|
||||||
event: 'sunset',
|
|
||||||
offset: '+00:30:00'
|
|
||||||
}],
|
|
||||||
action: [{
|
|
||||||
service: 'light.turn_on',
|
|
||||||
target: {
|
|
||||||
entity_id: ['light.living_room', 'light.kitchen']
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
brightness_pct: 70
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
expect(validate(basicAutomation)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate automation with conditions', () => {
|
|
||||||
const automationWithConditions = {
|
|
||||||
alias: 'Conditional Light Control',
|
|
||||||
mode: 'single',
|
|
||||||
trigger: [{
|
|
||||||
platform: 'state',
|
|
||||||
entity_id: 'binary_sensor.motion',
|
|
||||||
to: 'on'
|
|
||||||
}],
|
|
||||||
condition: [{
|
|
||||||
condition: 'and',
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
condition: 'time',
|
|
||||||
after: '22:00:00',
|
|
||||||
before: '06:00:00'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
condition: 'state',
|
|
||||||
entity_id: 'input_boolean.guest_mode',
|
|
||||||
state: 'off'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
action: [{
|
|
||||||
service: 'light.turn_on',
|
|
||||||
target: {
|
|
||||||
entity_id: 'light.hallway'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
expect(validate(automationWithConditions)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate automation with multiple triggers and actions', () => {
|
|
||||||
const complexAutomation = {
|
|
||||||
alias: 'Complex Automation',
|
|
||||||
mode: 'parallel',
|
|
||||||
trigger: [
|
|
||||||
{
|
|
||||||
platform: 'state',
|
|
||||||
entity_id: 'binary_sensor.door',
|
|
||||||
to: 'on'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
platform: 'state',
|
|
||||||
entity_id: 'binary_sensor.window',
|
|
||||||
to: 'on'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
condition: [{
|
|
||||||
condition: 'state',
|
|
||||||
entity_id: 'alarm_control_panel.home',
|
|
||||||
state: 'armed_away'
|
|
||||||
}],
|
|
||||||
action: [
|
|
||||||
{
|
|
||||||
service: 'notify.mobile_app',
|
|
||||||
data: {
|
|
||||||
message: 'Security alert: Movement detected!'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: 'light.turn_on',
|
|
||||||
target: {
|
|
||||||
entity_id: 'light.all_lights'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: 'camera.snapshot',
|
|
||||||
target: {
|
|
||||||
entity_id: 'camera.front_door'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
expect(validate(complexAutomation)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject automation without required fields', () => {
|
|
||||||
const invalidAutomation = {
|
|
||||||
description: 'Missing required fields'
|
|
||||||
// missing alias, trigger, and action
|
|
||||||
};
|
|
||||||
expect(validate(invalidAutomation)).toBe(false);
|
|
||||||
expect(validate.errors).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate all automation modes', () => {
|
|
||||||
const modes = ['single', 'parallel', 'queued', 'restart'];
|
|
||||||
modes.forEach(mode => {
|
|
||||||
const automation = {
|
|
||||||
alias: `Test ${mode} mode`,
|
|
||||||
mode,
|
|
||||||
trigger: [{
|
|
||||||
platform: 'state',
|
|
||||||
entity_id: 'input_boolean.test',
|
|
||||||
to: 'on'
|
|
||||||
}],
|
|
||||||
action: [{
|
|
||||||
service: 'light.turn_on',
|
|
||||||
target: {
|
|
||||||
entity_id: 'light.test'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
expect(validate(automation)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Device Control Schema', () => {
|
describe('Device Control Schema', () => {
|
||||||
const validate = ajv.compile(deviceControlSchema);
|
|
||||||
|
|
||||||
test('should validate light control command', () => {
|
test('should validate light control command', () => {
|
||||||
const lightCommand = {
|
const command = {
|
||||||
domain: 'light',
|
domain: 'light',
|
||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: 'light.living_room',
|
entity_id: 'light.living_room',
|
||||||
parameters: {
|
parameters: {
|
||||||
brightness: 255,
|
brightness_pct: 100
|
||||||
color_temp: 400,
|
|
||||||
transition: 2
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
expect(validate(lightCommand)).toBe(true);
|
const result = validateDeviceControl(command);
|
||||||
});
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
test('should validate climate control command', () => {
|
|
||||||
const climateCommand = {
|
|
||||||
domain: 'climate',
|
|
||||||
command: 'set_temperature',
|
|
||||||
entity_id: 'climate.living_room',
|
|
||||||
parameters: {
|
|
||||||
temperature: 22.5,
|
|
||||||
hvac_mode: 'heat',
|
|
||||||
target_temp_high: 24,
|
|
||||||
target_temp_low: 20
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expect(validate(climateCommand)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate cover control command', () => {
|
|
||||||
const coverCommand = {
|
|
||||||
domain: 'cover',
|
|
||||||
command: 'set_position',
|
|
||||||
entity_id: 'cover.garage_door',
|
|
||||||
parameters: {
|
|
||||||
position: 50,
|
|
||||||
tilt_position: 45
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expect(validate(coverCommand)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate fan control command', () => {
|
|
||||||
const fanCommand = {
|
|
||||||
domain: 'fan',
|
|
||||||
command: 'set_speed',
|
|
||||||
entity_id: 'fan.bedroom',
|
|
||||||
parameters: {
|
|
||||||
speed: 'medium',
|
|
||||||
oscillating: true,
|
|
||||||
direction: 'forward'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expect(validate(fanCommand)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject command with invalid domain', () => {
|
|
||||||
const invalidCommand = {
|
|
||||||
domain: 'invalid_domain',
|
|
||||||
command: 'turn_on',
|
|
||||||
entity_id: 'light.living_room'
|
|
||||||
};
|
|
||||||
expect(validate(invalidCommand)).toBe(false);
|
|
||||||
expect(validate.errors).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject command with mismatched domain and entity_id', () => {
|
test('should reject command with mismatched domain and entity_id', () => {
|
||||||
@@ -500,46 +261,18 @@ describe('Home Assistant Schemas', () => {
|
|||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: 'switch.living_room' // mismatched domain
|
entity_id: 'switch.living_room' // mismatched domain
|
||||||
};
|
};
|
||||||
expect(validate(mismatchedCommand)).toBe(false);
|
const result = validateDeviceControl(mismatchedCommand);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate command with array of entity_ids', () => {
|
test('should validate command with array of entity_ids', () => {
|
||||||
const multiEntityCommand = {
|
const command = {
|
||||||
domain: 'light',
|
domain: 'light',
|
||||||
command: 'turn_on',
|
command: 'turn_on',
|
||||||
entity_id: ['light.living_room', 'light.kitchen'],
|
entity_id: ['light.living_room', 'light.kitchen']
|
||||||
parameters: {
|
|
||||||
brightness: 255
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
expect(validate(multiEntityCommand)).toBe(true);
|
const result = validateDeviceControl(command);
|
||||||
});
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
test('should validate scene activation command', () => {
|
|
||||||
const sceneCommand = {
|
|
||||||
domain: 'scene',
|
|
||||||
command: 'turn_on',
|
|
||||||
entity_id: 'scene.movie_night',
|
|
||||||
parameters: {
|
|
||||||
transition: 2
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expect(validate(sceneCommand)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate script execution command', () => {
|
|
||||||
const scriptCommand = {
|
|
||||||
domain: 'script',
|
|
||||||
command: 'turn_on',
|
|
||||||
entity_id: 'script.welcome_home',
|
|
||||||
parameters: {
|
|
||||||
variables: {
|
|
||||||
user: 'John',
|
|
||||||
delay: 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expect(validate(scriptCommand)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,292 +1,93 @@
|
|||||||
import { JSONSchemaType } from "ajv";
|
import { z } from 'zod';
|
||||||
import { Entity, StateChangedEvent } from "../types/hass.js";
|
|
||||||
|
|
||||||
// Define base types for automation components
|
// Entity Schema
|
||||||
type TriggerType = {
|
const entitySchema = z.object({
|
||||||
platform: string;
|
entity_id: z.string().regex(/^[a-z0-9_]+\.[a-z0-9_]+$/),
|
||||||
event?: string | null;
|
state: z.string(),
|
||||||
entity_id?: string | null;
|
attributes: z.record(z.any()),
|
||||||
to?: string | null;
|
last_changed: z.string(),
|
||||||
from?: string | null;
|
last_updated: z.string(),
|
||||||
offset?: string | null;
|
context: z.object({
|
||||||
[key: string]: any;
|
id: z.string(),
|
||||||
|
parent_id: z.string().nullable(),
|
||||||
|
user_id: z.string().nullable()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Schema
|
||||||
|
const serviceSchema = z.object({
|
||||||
|
domain: z.string().min(1),
|
||||||
|
service: z.string().min(1),
|
||||||
|
target: z.object({
|
||||||
|
entity_id: z.union([z.string(), z.array(z.string())]),
|
||||||
|
device_id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
area_id: z.union([z.string(), z.array(z.string())]).optional()
|
||||||
|
}).optional(),
|
||||||
|
service_data: z.record(z.any()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// State Changed Event Schema
|
||||||
|
const stateChangedEventSchema = z.object({
|
||||||
|
event_type: z.literal('state_changed'),
|
||||||
|
data: z.object({
|
||||||
|
entity_id: z.string(),
|
||||||
|
old_state: z.union([entitySchema, z.null()]),
|
||||||
|
new_state: entitySchema
|
||||||
|
}),
|
||||||
|
origin: z.string(),
|
||||||
|
time_fired: z.string(),
|
||||||
|
context: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
parent_id: z.string().nullable(),
|
||||||
|
user_id: z.string().nullable()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config Schema
|
||||||
|
const configSchema = z.object({
|
||||||
|
location_name: z.string(),
|
||||||
|
time_zone: z.string(),
|
||||||
|
components: z.array(z.string()),
|
||||||
|
version: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Device Control Schema
|
||||||
|
const deviceControlSchema = z.object({
|
||||||
|
domain: z.string().min(1),
|
||||||
|
command: z.string().min(1),
|
||||||
|
entity_id: z.union([z.string(), z.array(z.string())]),
|
||||||
|
parameters: z.record(z.any()).optional()
|
||||||
|
}).refine(data => {
|
||||||
|
if (typeof data.entity_id === 'string') {
|
||||||
|
return data.entity_id.startsWith(data.domain + '.');
|
||||||
|
}
|
||||||
|
return data.entity_id.every(id => id.startsWith(data.domain + '.'));
|
||||||
|
}, {
|
||||||
|
message: 'entity_id must match the domain'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation functions
|
||||||
|
export const validateEntity = (data: unknown) => {
|
||||||
|
const result = entitySchema.safeParse(data);
|
||||||
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConditionType = {
|
export const validateService = (data: unknown) => {
|
||||||
condition: string;
|
const result = serviceSchema.safeParse(data);
|
||||||
conditions?: Array<Record<string, any>> | null;
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
[key: string]: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionType = {
|
export const validateStateChangedEvent = (data: unknown) => {
|
||||||
service: string;
|
const result = stateChangedEventSchema.safeParse(data);
|
||||||
target?: {
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
entity_id?: string | string[] | null;
|
|
||||||
[key: string]: any;
|
|
||||||
} | null;
|
|
||||||
data?: Record<string, any> | null;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AutomationType = {
|
export const validateConfig = (data: unknown) => {
|
||||||
alias: string;
|
const result = configSchema.safeParse(data);
|
||||||
description?: string | null;
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
mode?: ("single" | "parallel" | "queued" | "restart") | null;
|
|
||||||
trigger: TriggerType[];
|
|
||||||
condition?: ConditionType[] | null;
|
|
||||||
action: ActionType[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeviceControlType = {
|
export const validateDeviceControl = (data: unknown) => {
|
||||||
domain:
|
const result = deviceControlSchema.safeParse(data);
|
||||||
| "light"
|
return { success: result.success, error: result.success ? undefined : result.error };
|
||||||
| "switch"
|
};
|
||||||
| "climate"
|
|
||||||
| "cover"
|
|
||||||
| "fan"
|
|
||||||
| "scene"
|
|
||||||
| "script"
|
|
||||||
| "media_player";
|
|
||||||
command: string;
|
|
||||||
entity_id: string | string[];
|
|
||||||
parameters?: Record<string, any> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define missing types
|
|
||||||
export interface Service {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
target?: {
|
|
||||||
entity?: string[];
|
|
||||||
device?: string[];
|
|
||||||
area?: string[];
|
|
||||||
} | null;
|
|
||||||
fields: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
components: string[];
|
|
||||||
config_dir: string;
|
|
||||||
elevation: number;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
location_name: string;
|
|
||||||
time_zone: string;
|
|
||||||
unit_system: {
|
|
||||||
length: string;
|
|
||||||
mass: string;
|
|
||||||
temperature: string;
|
|
||||||
volume: string;
|
|
||||||
};
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define base schemas
|
|
||||||
const contextSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: { type: "string" },
|
|
||||||
parent_id: { type: "string", nullable: true },
|
|
||||||
user_id: { type: "string", nullable: true },
|
|
||||||
},
|
|
||||||
required: ["id", "parent_id", "user_id"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Entity schema
|
|
||||||
export const entitySchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
entity_id: { type: "string" },
|
|
||||||
state: { type: "string" },
|
|
||||||
attributes: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
last_changed: { type: "string" },
|
|
||||||
last_updated: { type: "string" },
|
|
||||||
context: contextSchema,
|
|
||||||
},
|
|
||||||
required: [
|
|
||||||
"entity_id",
|
|
||||||
"state",
|
|
||||||
"attributes",
|
|
||||||
"last_changed",
|
|
||||||
"last_updated",
|
|
||||||
"context",
|
|
||||||
],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Service schema
|
|
||||||
export const serviceSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string" },
|
|
||||||
description: { type: "string" },
|
|
||||||
target: {
|
|
||||||
type: "object",
|
|
||||||
nullable: true,
|
|
||||||
properties: {
|
|
||||||
entity: { type: "array", items: { type: "string" }, nullable: true },
|
|
||||||
device: { type: "array", items: { type: "string" }, nullable: true },
|
|
||||||
area: { type: "array", items: { type: "string" }, nullable: true },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name", "description", "fields"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Define the trigger schema without type assertion
|
|
||||||
export const triggerSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
platform: { type: "string" },
|
|
||||||
event: { type: "string", nullable: true },
|
|
||||||
entity_id: { type: "string", nullable: true },
|
|
||||||
to: { type: "string", nullable: true },
|
|
||||||
from: { type: "string", nullable: true },
|
|
||||||
offset: { type: "string", nullable: true },
|
|
||||||
},
|
|
||||||
required: ["platform"],
|
|
||||||
additionalProperties: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define the automation schema
|
|
||||||
export const automationSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
alias: { type: "string" },
|
|
||||||
description: { type: "string", nullable: true },
|
|
||||||
mode: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["single", "parallel", "queued", "restart"],
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
trigger: {
|
|
||||||
type: "array",
|
|
||||||
items: triggerSchema,
|
|
||||||
},
|
|
||||||
condition: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["alias", "trigger", "action"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deviceControlSchema: JSONSchemaType<DeviceControlType> = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
domain: {
|
|
||||||
type: "string",
|
|
||||||
enum: [
|
|
||||||
"light",
|
|
||||||
"switch",
|
|
||||||
"climate",
|
|
||||||
"cover",
|
|
||||||
"fan",
|
|
||||||
"scene",
|
|
||||||
"script",
|
|
||||||
"media_player",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
command: { type: "string" },
|
|
||||||
entity_id: {
|
|
||||||
anyOf: [
|
|
||||||
{ type: "string" },
|
|
||||||
{
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
nullable: true,
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["domain", "command", "entity_id"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// State changed event schema
|
|
||||||
export const stateChangedEventSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
event_type: { type: "string", const: "state_changed" },
|
|
||||||
data: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
entity_id: { type: "string" },
|
|
||||||
new_state: { ...entitySchema, nullable: true },
|
|
||||||
old_state: { ...entitySchema, nullable: true },
|
|
||||||
},
|
|
||||||
required: ["entity_id", "new_state", "old_state"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
origin: { type: "string" },
|
|
||||||
time_fired: { type: "string" },
|
|
||||||
context: contextSchema,
|
|
||||||
},
|
|
||||||
required: ["event_type", "data", "origin", "time_fired", "context"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Config schema
|
|
||||||
export const configSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
components: { type: "array", items: { type: "string" } },
|
|
||||||
config_dir: { type: "string" },
|
|
||||||
elevation: { type: "number" },
|
|
||||||
latitude: { type: "number" },
|
|
||||||
longitude: { type: "number" },
|
|
||||||
location_name: { type: "string" },
|
|
||||||
time_zone: { type: "string" },
|
|
||||||
unit_system: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
length: { type: "string" },
|
|
||||||
mass: { type: "string" },
|
|
||||||
temperature: { type: "string" },
|
|
||||||
volume: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["length", "mass", "temperature", "volume"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
version: { type: "string" },
|
|
||||||
},
|
|
||||||
required: [
|
|
||||||
"components",
|
|
||||||
"config_dir",
|
|
||||||
"elevation",
|
|
||||||
"latitude",
|
|
||||||
"longitude",
|
|
||||||
"location_name",
|
|
||||||
"time_zone",
|
|
||||||
"unit_system",
|
|
||||||
"version",
|
|
||||||
],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
Reference in New Issue
Block a user