@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.
Files changed (294) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +149 -0
  3. package/.gitattributes +30 -0
  4. package/Dockerfile +33 -0
  5. package/LICENSE +21 -0
  6. package/README.md +394 -0
  7. package/dist/config.d.ts +33 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +183 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/haClient/automations.d.ts +67 -0
  12. package/dist/haClient/automations.d.ts.map +1 -0
  13. package/dist/haClient/automations.js +181 -0
  14. package/dist/haClient/automations.js.map +1 -0
  15. package/dist/haClient/config.d.ts +28 -0
  16. package/dist/haClient/config.d.ts.map +1 -0
  17. package/dist/haClient/config.js +38 -0
  18. package/dist/haClient/config.js.map +1 -0
  19. package/dist/haClient/devices.d.ts +219 -0
  20. package/dist/haClient/devices.d.ts.map +1 -0
  21. package/dist/haClient/devices.js +506 -0
  22. package/dist/haClient/devices.js.map +1 -0
  23. package/dist/haClient/entities.d.ts +35 -0
  24. package/dist/haClient/entities.d.ts.map +1 -0
  25. package/dist/haClient/entities.js +97 -0
  26. package/dist/haClient/entities.js.map +1 -0
  27. package/dist/haClient/events.d.ts +138 -0
  28. package/dist/haClient/events.d.ts.map +1 -0
  29. package/dist/haClient/events.js +344 -0
  30. package/dist/haClient/events.js.map +1 -0
  31. package/dist/haClient/history.d.ts +26 -0
  32. package/dist/haClient/history.d.ts.map +1 -0
  33. package/dist/haClient/history.js +54 -0
  34. package/dist/haClient/history.js.map +1 -0
  35. package/dist/haClient/index.d.ts +153 -0
  36. package/dist/haClient/index.d.ts.map +1 -0
  37. package/dist/haClient/index.js +223 -0
  38. package/dist/haClient/index.js.map +1 -0
  39. package/dist/haClient/request.d.ts +34 -0
  40. package/dist/haClient/request.d.ts.map +1 -0
  41. package/dist/haClient/request.js +93 -0
  42. package/dist/haClient/request.js.map +1 -0
  43. package/dist/haClient/services.d.ts +22 -0
  44. package/dist/haClient/services.d.ts.map +1 -0
  45. package/dist/haClient/services.js +39 -0
  46. package/dist/haClient/services.js.map +1 -0
  47. package/dist/haClient/states.d.ts +26 -0
  48. package/dist/haClient/states.d.ts.map +1 -0
  49. package/dist/haClient/states.js +53 -0
  50. package/dist/haClient/states.js.map +1 -0
  51. package/dist/haClient/updates.d.ts +22 -0
  52. package/dist/haClient/updates.d.ts.map +1 -0
  53. package/dist/haClient/updates.js +48 -0
  54. package/dist/haClient/updates.js.map +1 -0
  55. package/dist/haClient.d.ts +97 -0
  56. package/dist/haClient.d.ts.map +1 -0
  57. package/dist/haClient.js +344 -0
  58. package/dist/haClient.js.map +1 -0
  59. package/dist/index.d.ts +8 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +92 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/localAI/index.d.ts +37 -0
  64. package/dist/localAI/index.d.ts.map +1 -0
  65. package/dist/localAI/index.js +73 -0
  66. package/dist/localAI/index.js.map +1 -0
  67. package/dist/localAI/prompts.d.ts +20 -0
  68. package/dist/localAI/prompts.d.ts.map +1 -0
  69. package/dist/localAI/prompts.js +48 -0
  70. package/dist/localAI/prompts.js.map +1 -0
  71. package/dist/localAI/providers/base.d.ts +51 -0
  72. package/dist/localAI/providers/base.d.ts.map +1 -0
  73. package/dist/localAI/providers/base.js +80 -0
  74. package/dist/localAI/providers/base.js.map +1 -0
  75. package/dist/localAI/providers/ollama.d.ts +22 -0
  76. package/dist/localAI/providers/ollama.d.ts.map +1 -0
  77. package/dist/localAI/providers/ollama.js +63 -0
  78. package/dist/localAI/providers/ollama.js.map +1 -0
  79. package/dist/localAI/providers/openai.d.ts +23 -0
  80. package/dist/localAI/providers/openai.d.ts.map +1 -0
  81. package/dist/localAI/providers/openai.js +63 -0
  82. package/dist/localAI/providers/openai.js.map +1 -0
  83. package/dist/localAI/types.d.ts +61 -0
  84. package/dist/localAI/types.d.ts.map +1 -0
  85. package/dist/localAI/types.js +6 -0
  86. package/dist/localAI/types.js.map +1 -0
  87. package/dist/ollamaClient.d.ts +37 -0
  88. package/dist/ollamaClient.d.ts.map +1 -0
  89. package/dist/ollamaClient.js +110 -0
  90. package/dist/ollamaClient.js.map +1 -0
  91. package/dist/resources/index.d.ts +11 -0
  92. package/dist/resources/index.d.ts.map +1 -0
  93. package/dist/resources/index.js +238 -0
  94. package/dist/resources/index.js.map +1 -0
  95. package/dist/server/common.d.ts +9 -0
  96. package/dist/server/common.d.ts.map +1 -0
  97. package/dist/server/common.js +27 -0
  98. package/dist/server/common.js.map +1 -0
  99. package/dist/server/eventSubscription.d.ts +53 -0
  100. package/dist/server/eventSubscription.d.ts.map +1 -0
  101. package/dist/server/eventSubscription.js +257 -0
  102. package/dist/server/eventSubscription.js.map +1 -0
  103. package/dist/server/http.d.ts +12 -0
  104. package/dist/server/http.d.ts.map +1 -0
  105. package/dist/server/http.js +491 -0
  106. package/dist/server/http.js.map +1 -0
  107. package/dist/server/rateLimiter.d.ts +70 -0
  108. package/dist/server/rateLimiter.d.ts.map +1 -0
  109. package/dist/server/rateLimiter.js +156 -0
  110. package/dist/server/rateLimiter.js.map +1 -0
  111. package/dist/server/sse.d.ts +32 -0
  112. package/dist/server/sse.d.ts.map +1 -0
  113. package/dist/server/sse.js +76 -0
  114. package/dist/server/sse.js.map +1 -0
  115. package/dist/server/stdio.d.ts +8 -0
  116. package/dist/server/stdio.d.ts.map +1 -0
  117. package/dist/server/stdio.js +16 -0
  118. package/dist/server/stdio.js.map +1 -0
  119. package/dist/server/stream.d.ts +26 -0
  120. package/dist/server/stream.d.ts.map +1 -0
  121. package/dist/server/stream.js +81 -0
  122. package/dist/server/stream.js.map +1 -0
  123. package/dist/server.d.ts +8 -0
  124. package/dist/server.d.ts.map +1 -0
  125. package/dist/server.js +30 -0
  126. package/dist/server.js.map +1 -0
  127. package/dist/tools/callService.d.ts +8 -0
  128. package/dist/tools/callService.d.ts.map +1 -0
  129. package/dist/tools/callService.js +100 -0
  130. package/dist/tools/callService.js.map +1 -0
  131. package/dist/tools/getClient.d.ts +8 -0
  132. package/dist/tools/getClient.d.ts.map +1 -0
  133. package/dist/tools/getClient.js +94 -0
  134. package/dist/tools/getClient.js.map +1 -0
  135. package/dist/tools/getDevice.d.ts +8 -0
  136. package/dist/tools/getDevice.d.ts.map +1 -0
  137. package/dist/tools/getDevice.js +94 -0
  138. package/dist/tools/getDevice.js.map +1 -0
  139. package/dist/tools/getEntitiesByDomain.d.ts +8 -0
  140. package/dist/tools/getEntitiesByDomain.d.ts.map +1 -0
  141. package/dist/tools/getEntitiesByDomain.js +80 -0
  142. package/dist/tools/getEntitiesByDomain.js.map +1 -0
  143. package/dist/tools/getState.d.ts +8 -0
  144. package/dist/tools/getState.d.ts.map +1 -0
  145. package/dist/tools/getState.js +90 -0
  146. package/dist/tools/getState.js.map +1 -0
  147. package/dist/tools/getStates.d.ts +8 -0
  148. package/dist/tools/getStates.d.ts.map +1 -0
  149. package/dist/tools/getStates.js +70 -0
  150. package/dist/tools/getStates.js.map +1 -0
  151. package/dist/tools/index.d.ts +9 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +9 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/listClients.d.ts +8 -0
  156. package/dist/tools/listClients.d.ts.map +1 -0
  157. package/dist/tools/listClients.js +73 -0
  158. package/dist/tools/listClients.js.map +1 -0
  159. package/dist/tools/listDevices.d.ts +8 -0
  160. package/dist/tools/listDevices.d.ts.map +1 -0
  161. package/dist/tools/listDevices.js +73 -0
  162. package/dist/tools/listDevices.js.map +1 -0
  163. package/dist/tools/listSites.d.ts +8 -0
  164. package/dist/tools/listSites.d.ts.map +1 -0
  165. package/dist/tools/listSites.js +67 -0
  166. package/dist/tools/listSites.js.map +1 -0
  167. package/dist/tools/registry.d.ts +9 -0
  168. package/dist/tools/registry.d.ts.map +1 -0
  169. package/dist/tools/registry.js +1382 -0
  170. package/dist/tools/registry.js.map +1 -0
  171. package/dist/tools/searchDevices.d.ts +8 -0
  172. package/dist/tools/searchDevices.d.ts.map +1 -0
  173. package/dist/tools/searchDevices.js +84 -0
  174. package/dist/tools/searchDevices.js.map +1 -0
  175. package/dist/tools/searchEntities.d.ts +8 -0
  176. package/dist/tools/searchEntities.d.ts.map +1 -0
  177. package/dist/tools/searchEntities.js +80 -0
  178. package/dist/tools/searchEntities.js.map +1 -0
  179. package/dist/types/entity.d.ts +98 -0
  180. package/dist/types/entity.d.ts.map +1 -0
  181. package/dist/types/entity.js +2 -0
  182. package/dist/types/entity.js.map +1 -0
  183. package/dist/types/errors.d.ts +16 -0
  184. package/dist/types/errors.d.ts.map +1 -0
  185. package/dist/types/errors.js +32 -0
  186. package/dist/types/errors.js.map +1 -0
  187. package/dist/types/haConfig.d.ts +8 -0
  188. package/dist/types/haConfig.d.ts.map +1 -0
  189. package/dist/types/haConfig.js +2 -0
  190. package/dist/types/haConfig.js.map +1 -0
  191. package/dist/types/index.d.ts +6 -0
  192. package/dist/types/index.d.ts.map +1 -0
  193. package/dist/types/index.js +2 -0
  194. package/dist/types/index.js.map +1 -0
  195. package/dist/types/request.d.ts +7 -0
  196. package/dist/types/request.d.ts.map +1 -0
  197. package/dist/types/request.js +2 -0
  198. package/dist/types/request.js.map +1 -0
  199. package/dist/types/serviceCall.d.ts +18 -0
  200. package/dist/types/serviceCall.d.ts.map +1 -0
  201. package/dist/types/serviceCall.js +2 -0
  202. package/dist/types/serviceCall.js.map +1 -0
  203. package/dist/types.d.ts +2 -0
  204. package/dist/types.d.ts.map +1 -0
  205. package/dist/types.js +3 -0
  206. package/dist/types.js.map +1 -0
  207. package/dist/utils/config-validations.d.ts +12 -0
  208. package/dist/utils/config-validations.d.ts.map +1 -0
  209. package/dist/utils/config-validations.js +43 -0
  210. package/dist/utils/config-validations.js.map +1 -0
  211. package/dist/utils/logger.d.ts +24 -0
  212. package/dist/utils/logger.d.ts.map +1 -0
  213. package/dist/utils/logger.js +94 -0
  214. package/dist/utils/logger.js.map +1 -0
  215. package/dist/wrapper.d.ts +8 -0
  216. package/dist/wrapper.d.ts.map +1 -0
  217. package/dist/wrapper.js +301 -0
  218. package/dist/wrapper.js.map +1 -0
  219. package/docker-compose.yml +17 -0
  220. package/docs/CHANGELOG.md +86 -0
  221. package/docs/LOCAL_CLIENT.md +268 -0
  222. package/docs/SSE_API.md +341 -0
  223. package/eslint.config.js +33 -0
  224. package/n8n/ai-agent-safety-monitor.json +355 -0
  225. package/openapi.json +166 -0
  226. package/package.json +51 -0
  227. package/src/config.ts +260 -0
  228. package/src/haClient/automations.ts +217 -0
  229. package/src/haClient/config.ts +41 -0
  230. package/src/haClient/devices.ts +653 -0
  231. package/src/haClient/entities.ts +128 -0
  232. package/src/haClient/events.ts +456 -0
  233. package/src/haClient/history.ts +68 -0
  234. package/src/haClient/index.ts +287 -0
  235. package/src/haClient/request.ts +127 -0
  236. package/src/haClient/services.ts +45 -0
  237. package/src/haClient/states.ts +60 -0
  238. package/src/haClient/updates.ts +58 -0
  239. package/src/index.ts +99 -0
  240. package/src/localAI/index.ts +88 -0
  241. package/src/localAI/prompts.ts +50 -0
  242. package/src/localAI/providers/base.ts +120 -0
  243. package/src/localAI/providers/ollama.ts +78 -0
  244. package/src/localAI/providers/openai.ts +86 -0
  245. package/src/localAI/types.ts +67 -0
  246. package/src/resources/index.ts +266 -0
  247. package/src/server/common.ts +38 -0
  248. package/src/server/eventSubscription.ts +318 -0
  249. package/src/server/http.ts +546 -0
  250. package/src/server/rateLimiter.ts +195 -0
  251. package/src/server/sse.ts +120 -0
  252. package/src/server/stdio.ts +22 -0
  253. package/src/server/stream.ts +113 -0
  254. package/src/tools/callService.ts +114 -0
  255. package/src/tools/getEntitiesByDomain.ts +92 -0
  256. package/src/tools/getState.ts +103 -0
  257. package/src/tools/getStates.ts +80 -0
  258. package/src/tools/index.ts +9 -0
  259. package/src/tools/registry.ts +1521 -0
  260. package/src/tools/searchEntities.ts +92 -0
  261. package/src/types/entity.ts +110 -0
  262. package/src/types/errors.ts +33 -0
  263. package/src/types/haConfig.ts +7 -0
  264. package/src/types/index.ts +5 -0
  265. package/src/types/request.ts +6 -0
  266. package/src/types/serviceCall.ts +18 -0
  267. package/src/types.ts +2 -0
  268. package/src/utils/config-validations.ts +46 -0
  269. package/src/utils/logger.ts +114 -0
  270. package/src/wrapper.ts +340 -0
  271. package/tests/config.test.ts +332 -0
  272. package/tests/haClient/automations.test.ts +133 -0
  273. package/tests/haClient/config.test.ts +96 -0
  274. package/tests/haClient/entities.test.ts +188 -0
  275. package/tests/haClient/history.test.ts +170 -0
  276. package/tests/haClient/index.test.ts +221 -0
  277. package/tests/haClient/request.test.ts +217 -0
  278. package/tests/haClient/services.test.ts +120 -0
  279. package/tests/haClient/states.test.ts +119 -0
  280. package/tests/haClient/updates.test.ts +193 -0
  281. package/tests/index.test.ts +164 -0
  282. package/tests/localAI/index.test.ts +168 -0
  283. package/tests/localAI/ollama.test.ts +187 -0
  284. package/tests/localAI/openai.test.ts +257 -0
  285. package/tests/server/common.test.ts +64 -0
  286. package/tests/server/http.test.ts +326 -0
  287. package/tests/server/sse.test.ts +240 -0
  288. package/tests/server/stdio.test.ts +102 -0
  289. package/tests/server/stream.test.ts +287 -0
  290. package/tests/tools/registry.test.ts +355 -0
  291. package/tests/utils/config-validations.test.ts +289 -0
  292. package/tests/utils/logger.test.ts +327 -0
  293. package/tsconfig.json +25 -0
  294. 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
+ }