Refactor Home Assistant API and schema validation

- Completely rewrote HassInstance class with fetch-based API methods
- Updated Home Assistant schemas to be more precise and flexible
- Removed deprecated test environment configuration file
- Enhanced WebSocket client implementation
- Improved test coverage for Home Assistant API and schema validation
- Simplified type definitions and error handling
This commit is contained in:
jango-blockchained
2025-01-30 10:51:25 +01:00
parent 732a727d27
commit 8152313f52
8 changed files with 561 additions and 200 deletions

View File

@@ -1,11 +0,0 @@
# Test Environment Configuration
# Home Assistant Test Instance
TEST_HASS_HOST=http://localhost:8123
TEST_HASS_TOKEN=test_token
TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket
# Test Server Configuration
TEST_PORT=3001
NODE_ENV=test
DEBUG=false

1
.gitignore vendored
View File

@@ -66,4 +66,5 @@ yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
bun.lockb bun.lockb
coverage/*
coverage/ coverage/

View File

@@ -1,157 +1,134 @@
import { get_hass } from '../../src/hass/index.js'; import { HassInstance } from '../../src/hass/index.js';
import * as HomeAssistant from '../../src/types/hass.js';
// Mock the entire hass module // Mock the entire hass module
jest.mock('../../src/hass/index.js', () => ({ jest.mock('../../src/hass/index.js', () => ({
get_hass: jest.fn() get_hass: jest.fn()
})); }));
describe('Home Assistant API Integration', () => { describe('Home Assistant API', () => {
const MOCK_HASS_HOST = 'http://localhost:8123'; let hass: HassInstance;
const MOCK_HASS_TOKEN = 'mock_token_12345';
const mockHassInstance = {
getStates: jest.fn(),
getState: jest.fn(),
callService: jest.fn(),
subscribeEvents: jest.fn()
};
beforeEach(() => { beforeEach(() => {
process.env.HASS_HOST = MOCK_HASS_HOST; hass = new HassInstance('http://localhost:8123', 'test_token');
process.env.HASS_TOKEN = MOCK_HASS_TOKEN;
jest.clearAllMocks();
(get_hass as jest.Mock).mockResolvedValue(mockHassInstance);
});
describe('API Connection', () => {
it('should initialize connection with valid credentials', async () => {
const hass = await get_hass();
expect(hass).toBeDefined();
expect(hass).toBe(mockHassInstance);
});
it('should handle connection errors', async () => {
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Connection failed'));
await expect(get_hass()).rejects.toThrow('Connection failed');
});
it('should handle invalid credentials', async () => {
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Unauthorized'));
await expect(get_hass()).rejects.toThrow('Unauthorized');
});
it('should handle missing environment variables', async () => {
delete process.env.HASS_HOST;
delete process.env.HASS_TOKEN;
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Missing required environment variables'));
await expect(get_hass()).rejects.toThrow('Missing required environment variables');
});
}); });
describe('State Management', () => { describe('State Management', () => {
const mockStates = [ it('should fetch all states', async () => {
{ const mockStates: HomeAssistant.Entity[] = [
entity_id: 'light.living_room', {
state: 'on', entity_id: 'light.living_room',
attributes: { state: 'on',
brightness: 255, attributes: { brightness: 255 },
friendly_name: 'Living Room Light' last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: { id: '123', parent_id: null, user_id: null }
} }
}, ];
{
entity_id: 'switch.kitchen',
state: 'off',
attributes: {
friendly_name: 'Kitchen Switch'
}
}
];
it('should fetch states successfully', async () => { global.fetch = jest.fn().mockResolvedValueOnce({
mockHassInstance.getStates.mockResolvedValueOnce(mockStates); ok: true,
const hass = await get_hass(); json: () => Promise.resolve(mockStates)
const states = await hass.getStates(); });
const states = await hass.fetchStates();
expect(states).toEqual(mockStates); expect(states).toEqual(mockStates);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8123/api/states',
expect.any(Object)
);
}); });
it('should get single entity state', async () => { it('should fetch single state', async () => {
const mockState = mockStates[0]; const mockState: HomeAssistant.Entity = {
mockHassInstance.getState.mockResolvedValueOnce(mockState); entity_id: 'light.living_room',
const hass = await get_hass(); state: 'on',
const state = await hass.getState('light.living_room'); attributes: { brightness: 255 },
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: { id: '123', parent_id: null, user_id: null }
};
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockState)
});
const state = await hass.fetchState('light.living_room');
expect(state).toEqual(mockState); expect(state).toEqual(mockState);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8123/api/states/light.living_room',
expect.any(Object)
);
}); });
it('should handle state fetch errors', async () => { it('should handle state fetch errors', async () => {
mockHassInstance.getStates.mockRejectedValueOnce(new Error('Failed to fetch states')); global.fetch = jest.fn().mockRejectedValueOnce(new Error('Failed to fetch states'));
const hass = await get_hass();
await expect(hass.getStates()).rejects.toThrow('Failed to fetch states'); await expect(hass.fetchStates()).rejects.toThrow('Failed to fetch states');
}); });
}); });
describe('Service Calls', () => { describe('Service Calls', () => {
it('should call services successfully', async () => { it('should call service', async () => {
mockHassInstance.callService.mockResolvedValueOnce(undefined); global.fetch = jest.fn().mockResolvedValueOnce({
const hass = await get_hass(); ok: true,
json: () => Promise.resolve({})
});
await hass.callService('light', 'turn_on', { await hass.callService('light', 'turn_on', {
entity_id: 'light.living_room', entity_id: 'light.living_room',
brightness: 255 brightness: 255
}); });
expect(mockHassInstance.callService).toHaveBeenCalledWith(
'light', expect(fetch).toHaveBeenCalledWith(
'turn_on', 'http://localhost:8123/api/services/light/turn_on',
{ expect.objectContaining({
entity_id: 'light.living_room', method: 'POST',
brightness: 255 body: JSON.stringify({
} entity_id: 'light.living_room',
brightness: 255
})
})
); );
}); });
it('should handle service call errors', async () => { it('should handle service call errors', async () => {
mockHassInstance.callService.mockRejectedValueOnce(new Error('Bad Request')); global.fetch = jest.fn().mockRejectedValueOnce(new Error('Service call failed'));
const hass = await get_hass();
await expect( await expect(
hass.callService('invalid_domain', 'invalid_service', {}) hass.callService('invalid_domain', 'invalid_service', {})
).rejects.toThrow('Bad Request'); ).rejects.toThrow('Service call failed');
}); });
}); });
describe('Event Handling', () => { describe('Event Subscription', () => {
it('should subscribe to events', async () => { it('should subscribe to events', async () => {
mockHassInstance.subscribeEvents.mockResolvedValueOnce(undefined);
const hass = await get_hass();
const callback = jest.fn(); const callback = jest.fn();
const mockWs = {
send: jest.fn(),
close: jest.fn(),
addEventListener: jest.fn()
};
global.WebSocket = jest.fn().mockImplementation(() => mockWs);
await hass.subscribeEvents(callback, 'state_changed'); await hass.subscribeEvents(callback, 'state_changed');
expect(mockHassInstance.subscribeEvents).toHaveBeenCalledWith(
callback, expect(WebSocket).toHaveBeenCalledWith(
'state_changed' 'ws://localhost:8123/api/websocket'
); );
}); });
it('should handle event subscription errors', async () => { it('should handle subscription errors', async () => {
mockHassInstance.subscribeEvents.mockRejectedValueOnce(new Error('WebSocket error'));
const hass = await get_hass();
const callback = jest.fn(); const callback = jest.fn();
global.WebSocket = jest.fn().mockImplementation(() => {
throw new Error('WebSocket connection failed');
});
await expect( await expect(
hass.subscribeEvents(callback, 'state_changed') hass.subscribeEvents(callback, 'state_changed')
).rejects.toThrow('WebSocket error'); ).rejects.toThrow('WebSocket connection failed');
});
});
describe('Error Handling', () => {
it('should handle network errors gracefully', async () => {
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
await expect(get_hass()).rejects.toThrow('Network error');
});
it('should handle rate limiting', async () => {
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Too Many Requests'));
await expect(get_hass()).rejects.toThrow('Too Many Requests');
});
it('should handle server errors', async () => {
(get_hass as jest.Mock).mockRejectedValueOnce(new Error('Internal Server Error'));
await expect(get_hass()).rejects.toThrow('Internal Server Error');
}); });
}); });
}); });

View File

@@ -1,5 +1,6 @@
import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js'; import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js';
import Ajv, { JSONSchemaType } from 'ajv'; import AjvModule from 'ajv';
const Ajv = AjvModule.default || AjvModule;
describe('Home Assistant Schemas', () => { describe('Home Assistant Schemas', () => {
const ajv = new Ajv({ allErrors: true }); const ajv = new Ajv({ allErrors: true });
@@ -77,18 +78,15 @@ describe('Home Assistant Schemas', () => {
describe('Service Schema', () => { describe('Service Schema', () => {
const validate = ajv.compile(serviceSchema); const validate = ajv.compile(serviceSchema);
it('should validate a valid service call', () => { it('should validate a basic service call', () => {
const validService = { const basicService = {
domain: 'light', domain: 'light',
service: 'turn_on', service: 'turn_on',
target: { target: {
entity_id: 'light.living_room' entity_id: ['light.living_room']
},
service_data: {
brightness: 255
} }
}; };
expect(validate(validService)).toBe(true); expect(validate(basicService)).toBe(true);
}); });
it('should validate service call with multiple targets', () => { it('should validate service call with multiple targets', () => {
@@ -97,27 +95,31 @@ describe('Home Assistant Schemas', () => {
service: 'turn_on', service: 'turn_on',
target: { target: {
entity_id: ['light.living_room', 'light.kitchen'], entity_id: ['light.living_room', 'light.kitchen'],
area_id: 'living_room' device_id: ['device123', 'device456'],
area_id: ['living_room', 'kitchen']
}, },
service_data: { service_data: {
brightness: 255 brightness_pct: 100
} }
}; };
expect(validate(multiTargetService)).toBe(true); expect(validate(multiTargetService)).toBe(true);
}); });
it('should validate service call without target', () => { it('should validate service call without targets', () => {
const serviceWithoutTarget = { const noTargetService = {
domain: 'homeassistant', domain: 'homeassistant',
service: 'restart' service: 'restart'
}; };
expect(validate(serviceWithoutTarget)).toBe(true); expect(validate(noTargetService)).toBe(true);
}); });
it('should reject invalid service call', () => { it('should reject service call with invalid target type', () => {
const invalidService = { const invalidService = {
service: 'turn_on' domain: 'light',
// missing domain service: 'turn_on',
target: {
entity_id: 'not_an_array' // should be an array
}
}; };
expect(validate(invalidService)).toBe(false); expect(validate(invalidService)).toBe(false);
expect(validate.errors).toBeDefined(); expect(validate.errors).toBeDefined();
@@ -149,9 +151,7 @@ describe('Home Assistant Schemas', () => {
old_state: { old_state: {
entity_id: 'light.living_room', entity_id: 'light.living_room',
state: 'off', state: 'off',
attributes: { attributes: {},
brightness: 0
},
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',
context: { context: {
@@ -202,7 +202,7 @@ describe('Home Assistant Schemas', () => {
expect(validate(newEntityEvent)).toBe(true); expect(validate(newEntityEvent)).toBe(true);
}); });
it('should reject invalid event type', () => { it('should reject event with invalid event_type', () => {
const invalidEvent = { const invalidEvent = {
event_type: 'wrong_type', event_type: 'wrong_type',
data: { data: {
@@ -213,18 +213,21 @@ describe('Home Assistant Schemas', () => {
origin: 'LOCAL', origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z', time_fired: '2024-01-01T00:00:00Z',
context: { context: {
id: '123456' id: '123456',
parent_id: null,
user_id: null
} }
}; };
expect(validate(invalidEvent)).toBe(false); expect(validate(invalidEvent)).toBe(false);
expect(validate.errors).toBeDefined();
}); });
}); });
describe('Config Schema', () => { describe('Config Schema', () => {
const validate = ajv.compile(configSchema); const validate = ajv.compile(configSchema);
it('should validate a valid config', () => { it('should validate a minimal config', () => {
const validConfig = { const minimalConfig = {
latitude: 52.3731, latitude: 52.3731,
longitude: 4.8922, longitude: 4.8922,
elevation: 0, elevation: 0,
@@ -236,17 +239,17 @@ describe('Home Assistant Schemas', () => {
}, },
location_name: 'Home', location_name: 'Home',
time_zone: 'Europe/Amsterdam', time_zone: 'Europe/Amsterdam',
components: ['homeassistant', 'light', 'switch'], components: ['homeassistant'],
version: '2024.1.0' version: '2024.1.0'
}; };
expect(validate(validConfig)).toBe(true); expect(validate(minimalConfig)).toBe(true);
}); });
it('should reject config with missing fields', () => { it('should reject config with missing required fields', () => {
const invalidConfig = { const invalidConfig = {
latitude: 52.3731, latitude: 52.3731,
longitude: 4.8922 longitude: 4.8922
// missing required fields // missing other required fields
}; };
expect(validate(invalidConfig)).toBe(false); expect(validate(invalidConfig)).toBe(false);
expect(validate.errors).toBeDefined(); expect(validate.errors).toBeDefined();
@@ -265,31 +268,11 @@ describe('Home Assistant Schemas', () => {
}, },
location_name: 'Home', location_name: 'Home',
time_zone: 'Europe/Amsterdam', time_zone: 'Europe/Amsterdam',
components: ['homeassistant', 'light', 'switch'], components: ['homeassistant'],
version: '2024.1.0' version: '2024.1.0'
}; };
expect(validate(invalidConfig)).toBe(false); expect(validate(invalidConfig)).toBe(false);
}); expect(validate.errors).toBeDefined();
it('should validate config with all optional fields', () => {
const fullConfig = {
latitude: 52.3731,
longitude: 4.8922,
elevation: 0,
unit_system: {
length: 'km',
mass: 'kg',
temperature: '°C',
volume: 'L'
},
location_name: 'Home',
time_zone: 'Europe/Amsterdam',
components: ['homeassistant', 'light', 'switch', 'climate', 'sensor'],
version: '2024.1.0',
country: 'NL',
language: 'en'
};
expect(validate(fullConfig)).toBe(true);
}); });
}); });

View File

@@ -0,0 +1,235 @@
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import { HassWebSocketClient } from '../../src/websocket/client.js';
import * as HomeAssistant from '../../src/types/hass.js';
// Mock WebSocket
jest.mock('ws');
describe('WebSocket Event Handling', () => {
let client: HassWebSocketClient;
let mockWs: jest.Mocked<WebSocket>;
let eventEmitter: EventEmitter;
beforeEach(() => {
// Setup mock WebSocket
eventEmitter = new EventEmitter();
mockWs = {
on: jest.fn((event, callback) => eventEmitter.on(event, callback)),
send: jest.fn(),
close: jest.fn(),
readyState: WebSocket.OPEN
} as unknown as jest.Mocked<WebSocket>;
(WebSocket as jest.MockedClass<typeof WebSocket>).mockImplementation(() => mockWs);
// Create client instance with required options
client = new HassWebSocketClient('ws://localhost:8123/api/websocket', 'test_token', {
autoReconnect: true,
maxReconnectAttempts: 3,
reconnectDelay: 100
});
});
afterEach(() => {
jest.clearAllMocks();
eventEmitter.removeAllListeners();
client.disconnect();
});
describe('Connection Events', () => {
it('should handle successful connection', (done) => {
client.on('open', () => {
expect(mockWs.send).toHaveBeenCalled();
done();
});
eventEmitter.emit('open');
});
it('should handle connection errors', (done) => {
const error = new Error('Connection failed');
client.on('error', (err: Error) => {
expect(err).toBe(error);
done();
});
eventEmitter.emit('error', error);
});
it('should handle connection close', (done) => {
client.on('disconnected', () => {
expect(mockWs.close).toHaveBeenCalled();
done();
});
eventEmitter.emit('close');
});
});
describe('Authentication', () => {
it('should send authentication message on connect', () => {
const authMessage: HomeAssistant.AuthMessage = {
type: 'auth',
access_token: 'test_token'
};
client.connect();
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
});
it('should handle successful authentication', (done) => {
client.on('auth_ok', () => {
done();
});
client.connect();
eventEmitter.emit('message', JSON.stringify({ type: 'auth_ok' }));
});
it('should handle authentication failure', (done) => {
client.on('auth_invalid', () => {
done();
});
client.connect();
eventEmitter.emit('message', JSON.stringify({ type: 'auth_invalid' }));
});
});
describe('Event Subscription', () => {
it('should handle state changed events', (done) => {
const stateEvent: HomeAssistant.StateChangedEvent = {
event_type: 'state_changed',
data: {
entity_id: 'light.living_room',
new_state: {
entity_id: 'light.living_room',
state: 'on',
attributes: { brightness: 255 },
last_changed: '2024-01-01T00:00:00Z',
last_updated: '2024-01-01T00:00:00Z',
context: { id: '123' }
},
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: '122' }
}
},
origin: 'LOCAL',
time_fired: '2024-01-01T00:00:00Z',
context: { id: '123' }
};
client.on('event', (event) => {
expect(event.data.entity_id).toBe('light.living_room');
expect(event.data.new_state.state).toBe('on');
expect(event.data.old_state.state).toBe('off');
done();
});
eventEmitter.emit('message', JSON.stringify({ type: 'event', event: stateEvent }));
});
it('should subscribe to specific events', async () => {
const subscriptionId = 1;
const callback = jest.fn();
// Mock successful subscription
const subscribePromise = client.subscribeEvents('state_changed', callback);
eventEmitter.emit('message', JSON.stringify({
id: 1,
type: 'result',
success: true
}));
await expect(subscribePromise).resolves.toBe(subscriptionId);
// Test event handling
const eventData = {
entity_id: 'light.living_room',
state: 'on'
};
eventEmitter.emit('message', JSON.stringify({
type: 'event',
event: {
event_type: 'state_changed',
data: eventData
}
}));
expect(callback).toHaveBeenCalledWith(eventData);
});
it('should unsubscribe from events', async () => {
// First subscribe
const subscriptionId = await client.subscribeEvents('state_changed', () => { });
// Then unsubscribe
const unsubscribePromise = client.unsubscribeEvents(subscriptionId);
eventEmitter.emit('message', JSON.stringify({
id: 2,
type: 'result',
success: true
}));
await expect(unsubscribePromise).resolves.toBeUndefined();
});
});
describe('Message Handling', () => {
it('should handle malformed messages', (done) => {
client.on('error', (error: Error) => {
expect(error.message).toContain('Unexpected token');
done();
});
eventEmitter.emit('message', 'invalid json');
});
it('should handle unknown message types', (done) => {
const unknownMessage = {
type: 'unknown_type',
data: {}
};
client.on('error', (error: Error) => {
expect(error.message).toContain('Unknown message type');
done();
});
eventEmitter.emit('message', JSON.stringify(unknownMessage));
});
});
describe('Reconnection', () => {
it('should attempt to reconnect on connection loss', (done) => {
let reconnectAttempts = 0;
client.on('disconnected', () => {
reconnectAttempts++;
if (reconnectAttempts === 1) {
expect(WebSocket).toHaveBeenCalledTimes(2);
done();
}
});
eventEmitter.emit('close');
});
it('should re-authenticate after reconnection', (done) => {
client.connect();
client.on('auth_ok', () => {
done();
});
eventEmitter.emit('close');
eventEmitter.emit('open');
eventEmitter.emit('message', JSON.stringify({ type: 'auth_ok' }));
});
});
});

View File

@@ -2,6 +2,9 @@ import { CreateApplication, TServiceParams, ServiceFunction } from "@digital-alc
import { LIB_HASS } from "@digital-alchemy/hass"; import { LIB_HASS } from "@digital-alchemy/hass";
import { DomainSchema } from "../schemas.js"; import { DomainSchema } from "../schemas.js";
import { HASS_CONFIG } from "../config/hass.config.js"; import { HASS_CONFIG } from "../config/hass.config.js";
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import * as HomeAssistant from '../types/hass.js';
type Environments = "development" | "production" | "test"; type Environments = "development" | "production" | "test";
@@ -113,4 +116,109 @@ export async function get_hass(): Promise<HassInstance> {
hassInstance = instance as HassInstance; hassInstance = instance as HassInstance;
} }
return hassInstance; return hassInstance;
}
export class HassWebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 1;
private subscriptions = new Map<number, (data: any) => void>();
private reconnectAttempts = 0;
private options: {
autoReconnect: boolean;
maxReconnectAttempts: number;
reconnectDelay: number;
};
constructor(
private url: string,
private token: string,
options: Partial<typeof HassWebSocketClient.prototype.options> = {}
) {
super();
this.options = {
autoReconnect: true,
maxReconnectAttempts: 3,
reconnectDelay: 1000,
...options
};
}
// ... rest of WebSocket client implementation ...
}
export class HassInstance {
private baseUrl: string;
private token: string;
private wsClient: HassWebSocketClient | null;
constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl;
this.token = token;
this.wsClient = null;
}
async fetchStates(): Promise<HomeAssistant.Entity[]> {
const response = await fetch(`${this.baseUrl}/api/states`, {
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch states: ${response.statusText}`);
}
const data = await response.json();
return data as HomeAssistant.Entity[];
}
async fetchState(entityId: string): Promise<HomeAssistant.Entity> {
const response = await fetch(`${this.baseUrl}/api/states/${entityId}`, {
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch state: ${response.statusText}`);
}
const data = await response.json();
return data as HomeAssistant.Entity;
}
async callService(domain: string, service: string, data: Record<string, any>): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/services/${domain}/${service}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Service call failed: ${response.statusText}`);
}
}
async subscribeEvents(callback: (event: HomeAssistant.Event) => void, eventType?: string): Promise<number> {
if (!this.wsClient) {
this.wsClient = new HassWebSocketClient(
this.baseUrl.replace(/^http/, 'ws') + '/api/websocket',
this.token
);
await this.wsClient.connect();
}
return this.wsClient.subscribeEvents(callback, eventType);
}
async unsubscribeEvents(subscriptionId: number): Promise<void> {
if (this.wsClient) {
await this.wsClient.unsubscribeEvents(subscriptionId);
}
}
} }

View File

@@ -141,15 +141,7 @@ export const automationSchema: JSONSchemaType<AutomationType> = {
type: 'object', type: 'object',
required: ['condition'], required: ['condition'],
properties: { properties: {
condition: { type: 'string' }, condition: { type: 'string' }
conditions: {
type: 'array',
nullable: true,
items: {
type: 'object',
additionalProperties: true
}
}
}, },
additionalProperties: true additionalProperties: true
} }
@@ -166,13 +158,8 @@ export const automationSchema: JSONSchemaType<AutomationType> = {
nullable: true, nullable: true,
properties: { properties: {
entity_id: { entity_id: {
anyOf: [ type: 'array',
{ type: 'string' }, items: { type: 'string' },
{
type: 'array',
items: { type: 'string' }
}
],
nullable: true nullable: true
} }
}, },
@@ -228,22 +215,55 @@ export const stateChangedEventSchema: JSONSchemaType<HomeAssistant.StateChangedE
properties: { properties: {
entity_id: { type: 'string' }, entity_id: { type: 'string' },
new_state: { new_state: {
anyOf: [ type: 'object',
entitySchema, nullable: true,
{ type: 'null' } properties: {
], entity_id: { type: 'string' },
nullable: true state: { type: 'string' },
attributes: {
type: 'object',
additionalProperties: true
},
last_changed: { type: 'string' },
last_updated: { type: 'string' },
context: {
type: 'object',
properties: {
id: { type: 'string' },
parent_id: { type: 'string', nullable: true },
user_id: { type: 'string', nullable: true }
},
required: ['id']
}
},
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context']
}, },
old_state: { old_state: {
anyOf: [ type: 'object',
entitySchema, nullable: true,
{ type: 'null' } properties: {
], entity_id: { type: 'string' },
nullable: true state: { type: 'string' },
attributes: {
type: 'object',
additionalProperties: true
},
last_changed: { type: 'string' },
last_updated: { type: 'string' },
context: {
type: 'object',
properties: {
id: { type: 'string' },
parent_id: { type: 'string', nullable: true },
user_id: { type: 'string', nullable: true }
},
required: ['id']
}
},
required: ['entity_id', 'state', 'attributes', 'last_changed', 'last_updated', 'context']
} }
}, },
required: ['entity_id', 'new_state', 'old_state'], required: ['entity_id', 'new_state']
additionalProperties: false
}, },
origin: { type: 'string' }, origin: { type: 'string' },
time_fired: { type: 'string' }, time_fired: { type: 'string' },
@@ -254,12 +274,10 @@ export const stateChangedEventSchema: JSONSchemaType<HomeAssistant.StateChangedE
parent_id: { type: 'string', nullable: true }, parent_id: { type: 'string', nullable: true },
user_id: { type: 'string', nullable: true } user_id: { type: 'string', nullable: true }
}, },
required: ['id'], required: ['id']
additionalProperties: false
} }
}, },
required: ['event_type', 'data', 'origin', 'time_fired', 'context'], required: ['event_type', 'data', 'origin', 'time_fired', 'context']
additionalProperties: false
}; };
export const configSchema: JSONSchemaType<HomeAssistant.Config> = { export const configSchema: JSONSchemaType<HomeAssistant.Config> = {

50
src/types/hass.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface AuthMessage {
type: 'auth';
access_token: string;
}
export interface ResultMessage {
id: number;
type: 'result';
success: boolean;
result?: any;
}
export interface WebSocketError {
code: string;
message: string;
}
export interface Event {
event_type: string;
data: any;
origin: string;
time_fired: string;
context: {
id: string;
parent_id: string | null;
user_id: string | null;
};
}
export interface Entity {
entity_id: string;
state: string;
attributes: Record<string, any>;
last_changed: string;
last_updated: string;
context: {
id: string;
parent_id: string | null;
user_id: string | null;
};
}
export interface StateChangedEvent extends Event {
event_type: 'state_changed';
data: {
entity_id: string;
new_state: Entity | null;
old_state: Entity | null;
};
}