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:
@@ -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
1
.gitignore
vendored
@@ -66,4 +66,5 @@ yarn.lock
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
|
coverage/*
|
||||||
coverage/
|
coverage/
|
||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
235
__tests__/websocket/events.test.ts
Normal file
235
__tests__/websocket/events.test.ts
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -114,3 +117,108 @@ export async function get_hass(): Promise<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
50
src/types/hass.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user