@coffeerunhobby/mcp-ha-connect 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +8 -0
- package/.env.example +149 -0
- package/.gitattributes +30 -0
- package/Dockerfile +33 -0
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/haClient/automations.d.ts +67 -0
- package/dist/haClient/automations.d.ts.map +1 -0
- package/dist/haClient/automations.js +181 -0
- package/dist/haClient/automations.js.map +1 -0
- package/dist/haClient/config.d.ts +28 -0
- package/dist/haClient/config.d.ts.map +1 -0
- package/dist/haClient/config.js +38 -0
- package/dist/haClient/config.js.map +1 -0
- package/dist/haClient/devices.d.ts +219 -0
- package/dist/haClient/devices.d.ts.map +1 -0
- package/dist/haClient/devices.js +506 -0
- package/dist/haClient/devices.js.map +1 -0
- package/dist/haClient/entities.d.ts +35 -0
- package/dist/haClient/entities.d.ts.map +1 -0
- package/dist/haClient/entities.js +97 -0
- package/dist/haClient/entities.js.map +1 -0
- package/dist/haClient/events.d.ts +138 -0
- package/dist/haClient/events.d.ts.map +1 -0
- package/dist/haClient/events.js +344 -0
- package/dist/haClient/events.js.map +1 -0
- package/dist/haClient/history.d.ts +26 -0
- package/dist/haClient/history.d.ts.map +1 -0
- package/dist/haClient/history.js +54 -0
- package/dist/haClient/history.js.map +1 -0
- package/dist/haClient/index.d.ts +153 -0
- package/dist/haClient/index.d.ts.map +1 -0
- package/dist/haClient/index.js +223 -0
- package/dist/haClient/index.js.map +1 -0
- package/dist/haClient/request.d.ts +34 -0
- package/dist/haClient/request.d.ts.map +1 -0
- package/dist/haClient/request.js +93 -0
- package/dist/haClient/request.js.map +1 -0
- package/dist/haClient/services.d.ts +22 -0
- package/dist/haClient/services.d.ts.map +1 -0
- package/dist/haClient/services.js +39 -0
- package/dist/haClient/services.js.map +1 -0
- package/dist/haClient/states.d.ts +26 -0
- package/dist/haClient/states.d.ts.map +1 -0
- package/dist/haClient/states.js +53 -0
- package/dist/haClient/states.js.map +1 -0
- package/dist/haClient/updates.d.ts +22 -0
- package/dist/haClient/updates.d.ts.map +1 -0
- package/dist/haClient/updates.js +48 -0
- package/dist/haClient/updates.js.map +1 -0
- package/dist/haClient.d.ts +97 -0
- package/dist/haClient.d.ts.map +1 -0
- package/dist/haClient.js +344 -0
- package/dist/haClient.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/localAI/index.d.ts +37 -0
- package/dist/localAI/index.d.ts.map +1 -0
- package/dist/localAI/index.js +73 -0
- package/dist/localAI/index.js.map +1 -0
- package/dist/localAI/prompts.d.ts +20 -0
- package/dist/localAI/prompts.d.ts.map +1 -0
- package/dist/localAI/prompts.js +48 -0
- package/dist/localAI/prompts.js.map +1 -0
- package/dist/localAI/providers/base.d.ts +51 -0
- package/dist/localAI/providers/base.d.ts.map +1 -0
- package/dist/localAI/providers/base.js +80 -0
- package/dist/localAI/providers/base.js.map +1 -0
- package/dist/localAI/providers/ollama.d.ts +22 -0
- package/dist/localAI/providers/ollama.d.ts.map +1 -0
- package/dist/localAI/providers/ollama.js +63 -0
- package/dist/localAI/providers/ollama.js.map +1 -0
- package/dist/localAI/providers/openai.d.ts +23 -0
- package/dist/localAI/providers/openai.d.ts.map +1 -0
- package/dist/localAI/providers/openai.js +63 -0
- package/dist/localAI/providers/openai.js.map +1 -0
- package/dist/localAI/types.d.ts +61 -0
- package/dist/localAI/types.d.ts.map +1 -0
- package/dist/localAI/types.js +6 -0
- package/dist/localAI/types.js.map +1 -0
- package/dist/ollamaClient.d.ts +37 -0
- package/dist/ollamaClient.d.ts.map +1 -0
- package/dist/ollamaClient.js +110 -0
- package/dist/ollamaClient.js.map +1 -0
- package/dist/resources/index.d.ts +11 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +238 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server/common.d.ts +9 -0
- package/dist/server/common.d.ts.map +1 -0
- package/dist/server/common.js +27 -0
- package/dist/server/common.js.map +1 -0
- package/dist/server/eventSubscription.d.ts +53 -0
- package/dist/server/eventSubscription.d.ts.map +1 -0
- package/dist/server/eventSubscription.js +257 -0
- package/dist/server/eventSubscription.js.map +1 -0
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +491 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/rateLimiter.d.ts +70 -0
- package/dist/server/rateLimiter.d.ts.map +1 -0
- package/dist/server/rateLimiter.js +156 -0
- package/dist/server/rateLimiter.js.map +1 -0
- package/dist/server/sse.d.ts +32 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +76 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/stdio.d.ts +8 -0
- package/dist/server/stdio.d.ts.map +1 -0
- package/dist/server/stdio.js +16 -0
- package/dist/server/stdio.js.map +1 -0
- package/dist/server/stream.d.ts +26 -0
- package/dist/server/stream.d.ts.map +1 -0
- package/dist/server/stream.js +81 -0
- package/dist/server/stream.js.map +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +30 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/callService.d.ts +8 -0
- package/dist/tools/callService.d.ts.map +1 -0
- package/dist/tools/callService.js +100 -0
- package/dist/tools/callService.js.map +1 -0
- package/dist/tools/getClient.d.ts +8 -0
- package/dist/tools/getClient.d.ts.map +1 -0
- package/dist/tools/getClient.js +94 -0
- package/dist/tools/getClient.js.map +1 -0
- package/dist/tools/getDevice.d.ts +8 -0
- package/dist/tools/getDevice.d.ts.map +1 -0
- package/dist/tools/getDevice.js +94 -0
- package/dist/tools/getDevice.js.map +1 -0
- package/dist/tools/getEntitiesByDomain.d.ts +8 -0
- package/dist/tools/getEntitiesByDomain.d.ts.map +1 -0
- package/dist/tools/getEntitiesByDomain.js +80 -0
- package/dist/tools/getEntitiesByDomain.js.map +1 -0
- package/dist/tools/getState.d.ts +8 -0
- package/dist/tools/getState.d.ts.map +1 -0
- package/dist/tools/getState.js +90 -0
- package/dist/tools/getState.js.map +1 -0
- package/dist/tools/getStates.d.ts +8 -0
- package/dist/tools/getStates.d.ts.map +1 -0
- package/dist/tools/getStates.js +70 -0
- package/dist/tools/getStates.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/listClients.d.ts +8 -0
- package/dist/tools/listClients.d.ts.map +1 -0
- package/dist/tools/listClients.js +73 -0
- package/dist/tools/listClients.js.map +1 -0
- package/dist/tools/listDevices.d.ts +8 -0
- package/dist/tools/listDevices.d.ts.map +1 -0
- package/dist/tools/listDevices.js +73 -0
- package/dist/tools/listDevices.js.map +1 -0
- package/dist/tools/listSites.d.ts +8 -0
- package/dist/tools/listSites.d.ts.map +1 -0
- package/dist/tools/listSites.js +67 -0
- package/dist/tools/listSites.js.map +1 -0
- package/dist/tools/registry.d.ts +9 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +1382 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/searchDevices.d.ts +8 -0
- package/dist/tools/searchDevices.d.ts.map +1 -0
- package/dist/tools/searchDevices.js +84 -0
- package/dist/tools/searchDevices.js.map +1 -0
- package/dist/tools/searchEntities.d.ts +8 -0
- package/dist/tools/searchEntities.d.ts.map +1 -0
- package/dist/tools/searchEntities.js +80 -0
- package/dist/tools/searchEntities.js.map +1 -0
- package/dist/types/entity.d.ts +98 -0
- package/dist/types/entity.d.ts.map +1 -0
- package/dist/types/entity.js +2 -0
- package/dist/types/entity.js.map +1 -0
- package/dist/types/errors.d.ts +16 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +32 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/haConfig.d.ts +8 -0
- package/dist/types/haConfig.d.ts.map +1 -0
- package/dist/types/haConfig.js +2 -0
- package/dist/types/haConfig.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/request.d.ts +7 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +2 -0
- package/dist/types/request.js.map +1 -0
- package/dist/types/serviceCall.d.ts +18 -0
- package/dist/types/serviceCall.d.ts.map +1 -0
- package/dist/types/serviceCall.js +2 -0
- package/dist/types/serviceCall.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/config-validations.d.ts +12 -0
- package/dist/utils/config-validations.d.ts.map +1 -0
- package/dist/utils/config-validations.js +43 -0
- package/dist/utils/config-validations.js.map +1 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +94 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/wrapper.d.ts +8 -0
- package/dist/wrapper.d.ts.map +1 -0
- package/dist/wrapper.js +301 -0
- package/dist/wrapper.js.map +1 -0
- package/docker-compose.yml +17 -0
- package/docs/CHANGELOG.md +86 -0
- package/docs/LOCAL_CLIENT.md +268 -0
- package/docs/SSE_API.md +341 -0
- package/eslint.config.js +33 -0
- package/n8n/ai-agent-safety-monitor.json +355 -0
- package/openapi.json +166 -0
- package/package.json +51 -0
- package/src/config.ts +260 -0
- package/src/haClient/automations.ts +217 -0
- package/src/haClient/config.ts +41 -0
- package/src/haClient/devices.ts +653 -0
- package/src/haClient/entities.ts +128 -0
- package/src/haClient/events.ts +456 -0
- package/src/haClient/history.ts +68 -0
- package/src/haClient/index.ts +287 -0
- package/src/haClient/request.ts +127 -0
- package/src/haClient/services.ts +45 -0
- package/src/haClient/states.ts +60 -0
- package/src/haClient/updates.ts +58 -0
- package/src/index.ts +99 -0
- package/src/localAI/index.ts +88 -0
- package/src/localAI/prompts.ts +50 -0
- package/src/localAI/providers/base.ts +120 -0
- package/src/localAI/providers/ollama.ts +78 -0
- package/src/localAI/providers/openai.ts +86 -0
- package/src/localAI/types.ts +67 -0
- package/src/resources/index.ts +266 -0
- package/src/server/common.ts +38 -0
- package/src/server/eventSubscription.ts +318 -0
- package/src/server/http.ts +546 -0
- package/src/server/rateLimiter.ts +195 -0
- package/src/server/sse.ts +120 -0
- package/src/server/stdio.ts +22 -0
- package/src/server/stream.ts +113 -0
- package/src/tools/callService.ts +114 -0
- package/src/tools/getEntitiesByDomain.ts +92 -0
- package/src/tools/getState.ts +103 -0
- package/src/tools/getStates.ts +80 -0
- package/src/tools/index.ts +9 -0
- package/src/tools/registry.ts +1521 -0
- package/src/tools/searchEntities.ts +92 -0
- package/src/types/entity.ts +110 -0
- package/src/types/errors.ts +33 -0
- package/src/types/haConfig.ts +7 -0
- package/src/types/index.ts +5 -0
- package/src/types/request.ts +6 -0
- package/src/types/serviceCall.ts +18 -0
- package/src/types.ts +2 -0
- package/src/utils/config-validations.ts +46 -0
- package/src/utils/logger.ts +114 -0
- package/src/wrapper.ts +340 -0
- package/tests/config.test.ts +332 -0
- package/tests/haClient/automations.test.ts +133 -0
- package/tests/haClient/config.test.ts +96 -0
- package/tests/haClient/entities.test.ts +188 -0
- package/tests/haClient/history.test.ts +170 -0
- package/tests/haClient/index.test.ts +221 -0
- package/tests/haClient/request.test.ts +217 -0
- package/tests/haClient/services.test.ts +120 -0
- package/tests/haClient/states.test.ts +119 -0
- package/tests/haClient/updates.test.ts +193 -0
- package/tests/index.test.ts +164 -0
- package/tests/localAI/index.test.ts +168 -0
- package/tests/localAI/ollama.test.ts +187 -0
- package/tests/localAI/openai.test.ts +257 -0
- package/tests/server/common.test.ts +64 -0
- package/tests/server/http.test.ts +326 -0
- package/tests/server/sse.test.ts +240 -0
- package/tests/server/stdio.test.ts +102 -0
- package/tests/server/stream.test.ts +287 -0
- package/tests/tools/registry.test.ts +355 -0
- package/tests/utils/config-validations.test.ts +289 -0
- package/tests/utils/logger.test.ts +327 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Operations for Home Assistant API
|
|
3
|
+
* Handles entity listing, searching, and domain operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Entity, DomainSummary } from '../types/index.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
import type { StateOperations } from './states.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Entity-related operations for the Home Assistant API.
|
|
12
|
+
*/
|
|
13
|
+
export class EntityOperations {
|
|
14
|
+
constructor(private readonly stateOps: StateOperations) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get entities filtered by domain
|
|
18
|
+
*/
|
|
19
|
+
async getEntitiesByDomain(domain: string): Promise<Entity[]> {
|
|
20
|
+
logger.debug('Fetching entities by domain', { domain });
|
|
21
|
+
|
|
22
|
+
const allStates = await this.stateOps.getStates();
|
|
23
|
+
const filtered = allStates.filter(entity =>
|
|
24
|
+
entity.entity_id.startsWith(`${domain}.`)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
logger.info('Filtered entities by domain', {
|
|
28
|
+
domain,
|
|
29
|
+
count: filtered.length
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return filtered;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Search entities by name or entity_id
|
|
37
|
+
*/
|
|
38
|
+
async searchEntities(query: string): Promise<Entity[]> {
|
|
39
|
+
logger.debug('Searching entities', { query });
|
|
40
|
+
|
|
41
|
+
const allStates = await this.stateOps.getStates();
|
|
42
|
+
const lowerQuery = query.toLowerCase();
|
|
43
|
+
|
|
44
|
+
const results = allStates.filter(entity => {
|
|
45
|
+
const entityIdMatch = entity.entity_id.toLowerCase().includes(lowerQuery);
|
|
46
|
+
const nameMatch = entity.attributes.friendly_name &&
|
|
47
|
+
String(entity.attributes.friendly_name).toLowerCase().includes(lowerQuery);
|
|
48
|
+
|
|
49
|
+
return entityIdMatch || nameMatch;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
logger.info('Search completed', { query, results: results.length });
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get domain summary with entity counts and state breakdown
|
|
58
|
+
*/
|
|
59
|
+
async getDomainSummary(domain: string): Promise<DomainSummary> {
|
|
60
|
+
logger.debug('Fetching domain summary', { domain });
|
|
61
|
+
|
|
62
|
+
const entities = await this.getEntitiesByDomain(domain);
|
|
63
|
+
|
|
64
|
+
// Count states
|
|
65
|
+
const states: Record<string, number> = {};
|
|
66
|
+
const entityList: DomainSummary['entities'] = [];
|
|
67
|
+
|
|
68
|
+
for (const entity of entities) {
|
|
69
|
+
states[entity.state] = (states[entity.state] || 0) + 1;
|
|
70
|
+
entityList.push({
|
|
71
|
+
entity_id: entity.entity_id,
|
|
72
|
+
state: entity.state,
|
|
73
|
+
friendly_name: entity.attributes.friendly_name as string | undefined,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const summary: DomainSummary = {
|
|
78
|
+
domain,
|
|
79
|
+
count: entities.length,
|
|
80
|
+
states,
|
|
81
|
+
entities: entityList,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
logger.info('Fetched domain summary', { domain, count: summary.count });
|
|
85
|
+
return summary;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List entities with optional filtering
|
|
90
|
+
*/
|
|
91
|
+
async listEntities(options: {
|
|
92
|
+
domain?: string;
|
|
93
|
+
search?: string;
|
|
94
|
+
limit?: number;
|
|
95
|
+
state?: string;
|
|
96
|
+
} = {}): Promise<Entity[]> {
|
|
97
|
+
logger.debug('Listing entities', options);
|
|
98
|
+
|
|
99
|
+
let entities = await this.stateOps.getStates();
|
|
100
|
+
|
|
101
|
+
// Filter by domain
|
|
102
|
+
if (options.domain) {
|
|
103
|
+
entities = entities.filter(e => e.entity_id.startsWith(`${options.domain}.`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Filter by state
|
|
107
|
+
if (options.state) {
|
|
108
|
+
entities = entities.filter(e => e.state === options.state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter by search query
|
|
112
|
+
if (options.search) {
|
|
113
|
+
const query = options.search.toLowerCase();
|
|
114
|
+
entities = entities.filter(e =>
|
|
115
|
+
e.entity_id.toLowerCase().includes(query) ||
|
|
116
|
+
String(e.attributes.friendly_name || '').toLowerCase().includes(query)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply limit
|
|
121
|
+
if (options.limit && options.limit > 0) {
|
|
122
|
+
entities = entities.slice(0, options.limit);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
logger.info('Listed entities', { count: entities.length, ...options });
|
|
126
|
+
return entities;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Assistant WebSocket Event Subscription
|
|
3
|
+
* Connects to Home Assistant WebSocket API to receive real-time events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
export interface HaEvent {
|
|
11
|
+
event_type: string;
|
|
12
|
+
data: {
|
|
13
|
+
entity_id?: string;
|
|
14
|
+
old_state?: {
|
|
15
|
+
entity_id: string;
|
|
16
|
+
state: string;
|
|
17
|
+
attributes: Record<string, unknown>;
|
|
18
|
+
last_changed: string;
|
|
19
|
+
last_updated: string;
|
|
20
|
+
} | null;
|
|
21
|
+
new_state?: {
|
|
22
|
+
entity_id: string;
|
|
23
|
+
state: string;
|
|
24
|
+
attributes: Record<string, unknown>;
|
|
25
|
+
last_changed: string;
|
|
26
|
+
last_updated: string;
|
|
27
|
+
} | null;
|
|
28
|
+
domain?: string;
|
|
29
|
+
service?: string;
|
|
30
|
+
service_data?: Record<string, unknown>;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
};
|
|
33
|
+
origin: string;
|
|
34
|
+
time_fired: string;
|
|
35
|
+
context: {
|
|
36
|
+
id: string;
|
|
37
|
+
parent_id: string | null;
|
|
38
|
+
user_id: string | null;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EventSubscription {
|
|
43
|
+
id: string;
|
|
44
|
+
subscriptionId: number;
|
|
45
|
+
eventType?: string;
|
|
46
|
+
domain?: string;
|
|
47
|
+
entityId?: string;
|
|
48
|
+
callback: (event: HaEvent) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EventSubscriberConfig {
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
token: string;
|
|
54
|
+
reconnectInterval?: number;
|
|
55
|
+
maxReconnectAttempts?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Home Assistant Event Subscriber
|
|
60
|
+
* Subscribes to real-time events via WebSocket
|
|
61
|
+
*/
|
|
62
|
+
export class EventSubscriber extends EventEmitter {
|
|
63
|
+
private ws: WebSocket | null = null;
|
|
64
|
+
private messageId = 1;
|
|
65
|
+
private authenticated = false;
|
|
66
|
+
private subscriptions = new Map<string, EventSubscription>();
|
|
67
|
+
private pendingSubscriptions: Array<{ resolve: (id: number) => void; reject: (error: Error) => void }> = [];
|
|
68
|
+
private reconnectAttempts = 0;
|
|
69
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
private isConnecting = false;
|
|
71
|
+
private shouldReconnect = true;
|
|
72
|
+
|
|
73
|
+
private readonly config: EventSubscriberConfig;
|
|
74
|
+
private readonly reconnectInterval: number;
|
|
75
|
+
private readonly maxReconnectAttempts: number;
|
|
76
|
+
|
|
77
|
+
constructor(config: EventSubscriberConfig) {
|
|
78
|
+
super();
|
|
79
|
+
this.config = config;
|
|
80
|
+
this.reconnectInterval = config.reconnectInterval ?? 5000;
|
|
81
|
+
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 10;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Connect to Home Assistant WebSocket API
|
|
86
|
+
*/
|
|
87
|
+
async connect(): Promise<void> {
|
|
88
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
89
|
+
logger.debug('WebSocket already connected');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.isConnecting) {
|
|
94
|
+
logger.debug('WebSocket connection already in progress');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.isConnecting = true;
|
|
99
|
+
this.shouldReconnect = true;
|
|
100
|
+
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
try {
|
|
103
|
+
// Convert HTTP URL to WebSocket URL
|
|
104
|
+
const wsUrl = this.config.baseUrl
|
|
105
|
+
.replace(/^http/, 'ws')
|
|
106
|
+
.replace(/\/$/, '') + '/api/websocket';
|
|
107
|
+
|
|
108
|
+
logger.info('Connecting to Home Assistant WebSocket', { url: wsUrl });
|
|
109
|
+
|
|
110
|
+
this.ws = new WebSocket(wsUrl);
|
|
111
|
+
|
|
112
|
+
this.ws.on('open', () => {
|
|
113
|
+
logger.info('WebSocket connection established');
|
|
114
|
+
this.reconnectAttempts = 0;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.ws.on('message', (data: WebSocket.Data) => {
|
|
118
|
+
try {
|
|
119
|
+
const message = JSON.parse(data.toString());
|
|
120
|
+
this.handleMessage(message, resolve, reject);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logger.error('Failed to parse WebSocket message', { error });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.ws.on('close', (code, reason) => {
|
|
127
|
+
logger.info('WebSocket connection closed', { code, reason: reason.toString() });
|
|
128
|
+
this.authenticated = false;
|
|
129
|
+
this.isConnecting = false;
|
|
130
|
+
this.handleDisconnect();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.ws.on('error', (error) => {
|
|
134
|
+
logger.error('WebSocket error', { error: error.message });
|
|
135
|
+
this.isConnecting = false;
|
|
136
|
+
reject(error);
|
|
137
|
+
});
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.isConnecting = false;
|
|
140
|
+
reject(error);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Handle incoming WebSocket messages
|
|
147
|
+
*/
|
|
148
|
+
private handleMessage(
|
|
149
|
+
message: Record<string, unknown>,
|
|
150
|
+
resolveConnect: (value: void) => void,
|
|
151
|
+
rejectConnect: (error: Error) => void
|
|
152
|
+
): void {
|
|
153
|
+
const type = message.type as string;
|
|
154
|
+
|
|
155
|
+
switch (type) {
|
|
156
|
+
case 'auth_required':
|
|
157
|
+
// Send authentication
|
|
158
|
+
this.sendMessage({
|
|
159
|
+
type: 'auth',
|
|
160
|
+
access_token: this.config.token,
|
|
161
|
+
});
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'auth_ok':
|
|
165
|
+
logger.info('WebSocket authentication successful');
|
|
166
|
+
this.authenticated = true;
|
|
167
|
+
this.isConnecting = false;
|
|
168
|
+
this.emit('connected');
|
|
169
|
+
resolveConnect();
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'auth_invalid':
|
|
173
|
+
logger.error('WebSocket authentication failed', { message: message.message });
|
|
174
|
+
this.isConnecting = false;
|
|
175
|
+
rejectConnect(new Error(`Authentication failed: ${message.message}`));
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case 'result':
|
|
179
|
+
this.handleResult(message);
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'event':
|
|
183
|
+
this.handleEvent(message);
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
default:
|
|
187
|
+
logger.debug('Unhandled WebSocket message type', { type, message });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Handle result messages (responses to commands)
|
|
193
|
+
*/
|
|
194
|
+
private handleResult(message: Record<string, unknown>): void {
|
|
195
|
+
const id = message.id as number;
|
|
196
|
+
const success = message.success as boolean;
|
|
197
|
+
|
|
198
|
+
if (this.pendingSubscriptions.length > 0) {
|
|
199
|
+
const pending = this.pendingSubscriptions.shift()!;
|
|
200
|
+
if (success) {
|
|
201
|
+
pending.resolve(id);
|
|
202
|
+
} else {
|
|
203
|
+
const error = message.error as { message?: string } | undefined;
|
|
204
|
+
pending.reject(new Error(error?.message ?? 'Subscription failed'));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Handle event messages
|
|
211
|
+
*/
|
|
212
|
+
private handleEvent(message: Record<string, unknown>): void {
|
|
213
|
+
const event = message.event as HaEvent;
|
|
214
|
+
if (!event) return;
|
|
215
|
+
|
|
216
|
+
// Emit to all matching subscriptions
|
|
217
|
+
for (const sub of this.subscriptions.values()) {
|
|
218
|
+
if (this.eventMatchesSubscription(event, sub)) {
|
|
219
|
+
try {
|
|
220
|
+
sub.callback(event);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
logger.error('Error in event callback', { error, subscriptionId: sub.id });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Also emit on the EventEmitter for generic listeners
|
|
228
|
+
this.emit('event', event);
|
|
229
|
+
this.emit(`event:${event.event_type}`, event);
|
|
230
|
+
|
|
231
|
+
if (event.data.entity_id) {
|
|
232
|
+
this.emit(`entity:${event.data.entity_id}`, event);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (event.data.domain) {
|
|
236
|
+
this.emit(`domain:${event.data.domain}`, event);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if an event matches a subscription's filters
|
|
242
|
+
*/
|
|
243
|
+
private eventMatchesSubscription(event: HaEvent, sub: EventSubscription): boolean {
|
|
244
|
+
// Check event type filter
|
|
245
|
+
if (sub.eventType && event.event_type !== sub.eventType) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check domain filter (for state_changed events)
|
|
250
|
+
if (sub.domain && event.event_type === 'state_changed') {
|
|
251
|
+
const entityDomain = event.data.entity_id?.split('.')[0];
|
|
252
|
+
if (entityDomain !== sub.domain) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check entity filter
|
|
258
|
+
if (sub.entityId && event.data.entity_id !== sub.entityId) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Handle disconnection and attempt reconnect
|
|
267
|
+
*/
|
|
268
|
+
private handleDisconnect(): void {
|
|
269
|
+
if (!this.shouldReconnect) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
274
|
+
logger.error('Max reconnect attempts reached, giving up');
|
|
275
|
+
this.emit('disconnected', new Error('Max reconnect attempts reached'));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.reconnectAttempts++;
|
|
280
|
+
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1);
|
|
281
|
+
|
|
282
|
+
logger.info('Scheduling reconnect', {
|
|
283
|
+
attempt: this.reconnectAttempts,
|
|
284
|
+
maxAttempts: this.maxReconnectAttempts,
|
|
285
|
+
delayMs: delay,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
this.reconnectTimer = setTimeout(() => {
|
|
289
|
+
this.connect().catch((error) => {
|
|
290
|
+
logger.error('Reconnect failed', { error: error.message });
|
|
291
|
+
});
|
|
292
|
+
}, delay);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Subscribe to all events
|
|
297
|
+
*/
|
|
298
|
+
async subscribeEvents(callback: (event: HaEvent) => void): Promise<string> {
|
|
299
|
+
return this.subscribe({
|
|
300
|
+
callback,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Subscribe to specific event type
|
|
306
|
+
*/
|
|
307
|
+
async subscribeEventType(eventType: string, callback: (event: HaEvent) => void): Promise<string> {
|
|
308
|
+
return this.subscribe({
|
|
309
|
+
eventType,
|
|
310
|
+
callback,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Subscribe to state changes for a specific domain
|
|
316
|
+
*/
|
|
317
|
+
async subscribeDomain(domain: string, callback: (event: HaEvent) => void): Promise<string> {
|
|
318
|
+
return this.subscribe({
|
|
319
|
+
eventType: 'state_changed',
|
|
320
|
+
domain,
|
|
321
|
+
callback,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Subscribe to state changes for a specific entity
|
|
327
|
+
*/
|
|
328
|
+
async subscribeEntity(entityId: string, callback: (event: HaEvent) => void): Promise<string> {
|
|
329
|
+
return this.subscribe({
|
|
330
|
+
eventType: 'state_changed',
|
|
331
|
+
entityId,
|
|
332
|
+
callback,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Internal subscribe method
|
|
338
|
+
*/
|
|
339
|
+
private async subscribe(options: {
|
|
340
|
+
eventType?: string;
|
|
341
|
+
domain?: string;
|
|
342
|
+
entityId?: string;
|
|
343
|
+
callback: (event: HaEvent) => void;
|
|
344
|
+
}): Promise<string> {
|
|
345
|
+
if (!this.authenticated) {
|
|
346
|
+
await this.connect();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const subscriptionId = await this.sendSubscribeEvents(options.eventType);
|
|
350
|
+
const id = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
351
|
+
|
|
352
|
+
this.subscriptions.set(id, {
|
|
353
|
+
id,
|
|
354
|
+
subscriptionId,
|
|
355
|
+
eventType: options.eventType,
|
|
356
|
+
domain: options.domain,
|
|
357
|
+
entityId: options.entityId,
|
|
358
|
+
callback: options.callback,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
logger.info('Created event subscription', {
|
|
362
|
+
id,
|
|
363
|
+
eventType: options.eventType,
|
|
364
|
+
domain: options.domain,
|
|
365
|
+
entityId: options.entityId,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return id;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Send subscribe_events command
|
|
373
|
+
*/
|
|
374
|
+
private sendSubscribeEvents(eventType?: string): Promise<number> {
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
this.pendingSubscriptions.push({ resolve, reject });
|
|
377
|
+
|
|
378
|
+
const message: Record<string, unknown> = {
|
|
379
|
+
id: this.messageId++,
|
|
380
|
+
type: 'subscribe_events',
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (eventType) {
|
|
384
|
+
message.event_type = eventType;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
this.sendMessage(message);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Unsubscribe from events
|
|
393
|
+
*/
|
|
394
|
+
async unsubscribe(subscriptionId: string): Promise<void> {
|
|
395
|
+
const sub = this.subscriptions.get(subscriptionId);
|
|
396
|
+
if (!sub) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Send unsubscribe command
|
|
401
|
+
this.sendMessage({
|
|
402
|
+
id: this.messageId++,
|
|
403
|
+
type: 'unsubscribe_events',
|
|
404
|
+
subscription: sub.subscriptionId,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
this.subscriptions.delete(subscriptionId);
|
|
408
|
+
logger.info('Removed event subscription', { id: subscriptionId });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Send a message over WebSocket
|
|
413
|
+
*/
|
|
414
|
+
private sendMessage(message: Record<string, unknown>): void {
|
|
415
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
416
|
+
throw new Error('WebSocket is not connected');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.ws.send(JSON.stringify(message));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Disconnect from Home Assistant
|
|
424
|
+
*/
|
|
425
|
+
disconnect(): void {
|
|
426
|
+
this.shouldReconnect = false;
|
|
427
|
+
|
|
428
|
+
if (this.reconnectTimer) {
|
|
429
|
+
clearTimeout(this.reconnectTimer);
|
|
430
|
+
this.reconnectTimer = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (this.ws) {
|
|
434
|
+
this.ws.close();
|
|
435
|
+
this.ws = null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.authenticated = false;
|
|
439
|
+
this.subscriptions.clear();
|
|
440
|
+
logger.info('Disconnected from Home Assistant WebSocket');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Check if connected
|
|
445
|
+
*/
|
|
446
|
+
isConnected(): boolean {
|
|
447
|
+
return this.ws?.readyState === WebSocket.OPEN && this.authenticated;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get active subscription count
|
|
452
|
+
*/
|
|
453
|
+
getSubscriptionCount(): number {
|
|
454
|
+
return this.subscriptions.size;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Operations for Home Assistant API
|
|
3
|
+
* Handles historical data and system logs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Entity, LogbookEntry } from '../types/index.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
import type { RequestHandler } from './request.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* History-related operations for the Home Assistant API.
|
|
12
|
+
*/
|
|
13
|
+
export class HistoryOperations {
|
|
14
|
+
constructor(private readonly request: RequestHandler) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get historical data for an entity
|
|
18
|
+
*/
|
|
19
|
+
async getHistory(entityId: string, hours: number = 24): Promise<Entity[][]> {
|
|
20
|
+
logger.debug('Fetching history', { entityId, hours });
|
|
21
|
+
|
|
22
|
+
const endTime = new Date();
|
|
23
|
+
const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000);
|
|
24
|
+
|
|
25
|
+
const path = `/history/period/${startTime.toISOString()}`;
|
|
26
|
+
const history = await this.request.get<Entity[][]>(path, {
|
|
27
|
+
filter_entity_id: entityId,
|
|
28
|
+
end_time: endTime.toISOString(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
logger.info('Fetched history', {
|
|
32
|
+
entityId,
|
|
33
|
+
hours,
|
|
34
|
+
records: history[0]?.length ?? 0
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return history;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get system log entries from the logbook
|
|
42
|
+
* Returns recent events/state changes, optionally filtered by entity
|
|
43
|
+
*/
|
|
44
|
+
async getSystemLog(options: {
|
|
45
|
+
hours?: number;
|
|
46
|
+
entity_id?: string;
|
|
47
|
+
} = {}): Promise<LogbookEntry[]> {
|
|
48
|
+
const hours = options.hours || 24;
|
|
49
|
+
logger.debug('Fetching system log', { hours, entity_id: options.entity_id });
|
|
50
|
+
|
|
51
|
+
const endTime = new Date();
|
|
52
|
+
const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000);
|
|
53
|
+
|
|
54
|
+
const path = `/logbook/${startTime.toISOString()}`;
|
|
55
|
+
const params: Record<string, string> = {
|
|
56
|
+
end_time: endTime.toISOString(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (options.entity_id) {
|
|
60
|
+
params.entity = options.entity_id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const entries = await this.request.get<LogbookEntry[]>(path, params);
|
|
64
|
+
|
|
65
|
+
logger.info('Fetched system log', { count: entries.length, hours });
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
}
|