@glasshome/sync-layer 0.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/area-views-B-m8vwce.js +321 -0
  4. package/dist/camera/fetch.d.ts +15 -0
  5. package/dist/camera/query.d.ts +15 -0
  6. package/dist/camera/types.d.ts +85 -0
  7. package/dist/camera/webrtc.d.ts +31 -0
  8. package/dist/commands/service.d.ts +53 -0
  9. package/dist/commands/types.d.ts +113 -0
  10. package/dist/connection/adapter.d.ts +48 -0
  11. package/dist/connection/auth.d.ts +126 -0
  12. package/dist/connection/manager.d.ts +42 -0
  13. package/dist/connection/subscriptions.d.ts +25 -0
  14. package/dist/connection/types.d.ts +161 -0
  15. package/dist/core/reducers.d.ts +99 -0
  16. package/dist/core/store.d.ts +64 -0
  17. package/dist/core/types.d.ts +336 -0
  18. package/dist/demo/demo-data.d.ts +24 -0
  19. package/dist/demo/demo-provider.d.ts +33 -0
  20. package/dist/demo/demo-provider.js +103 -0
  21. package/dist/demo-data-D8JXWRpw.js +419 -0
  22. package/dist/entities/area-views.d.ts +25 -0
  23. package/dist/entities/index.d.ts +54 -0
  24. package/dist/entities/views.d.ts +30 -0
  25. package/dist/history/constants.d.ts +16 -0
  26. package/dist/history/fetch.d.ts +19 -0
  27. package/dist/history/query.d.ts +13 -0
  28. package/dist/history/types.d.ts +146 -0
  29. package/dist/index.d.ts +38 -0
  30. package/dist/index.js +1747 -0
  31. package/dist/query/area-builder.d.ts +25 -0
  32. package/dist/query/builder.d.ts +18 -0
  33. package/dist/query/helpers.d.ts +21 -0
  34. package/dist/query/memoization.d.ts +18 -0
  35. package/dist/query/projections.d.ts +74 -0
  36. package/dist/query/types.d.ts +260 -0
  37. package/dist/solid/hooks.d.ts +88 -0
  38. package/dist/solid/index.d.ts +10 -0
  39. package/dist/solid/index.js +176 -0
  40. package/dist/solid/indices.d.ts +14 -0
  41. package/dist/store-B1sbyexX.js +54 -0
  42. package/dist/testing/fixtures.d.ts +161 -0
  43. package/dist/testing/helpers.d.ts +160 -0
  44. package/dist/testing/index.d.ts +13 -0
  45. package/dist/testing/index.js +2047 -0
  46. package/dist/testing/mock-behaviors/base.d.ts +73 -0
  47. package/dist/testing/mock-behaviors/climate.d.ts +22 -0
  48. package/dist/testing/mock-behaviors/cover.d.ts +22 -0
  49. package/dist/testing/mock-behaviors/index.d.ts +11 -0
  50. package/dist/testing/mock-behaviors/light.d.ts +20 -0
  51. package/dist/testing/mock-behaviors/lock.d.ts +20 -0
  52. package/dist/testing/mock-behaviors/media_player.d.ts +42 -0
  53. package/dist/testing/mock-behaviors/registry.d.ts +55 -0
  54. package/dist/testing/mock-behaviors/switch.d.ts +17 -0
  55. package/dist/testing/mock.d.ts +191 -0
  56. package/dist/testing/types.d.ts +87 -0
  57. package/dist/types-Csog0nqk.js +10 -0
  58. package/dist/weather/fetch.d.ts +19 -0
  59. package/dist/weather/query.d.ts +23 -0
  60. package/dist/weather/types.d.ts +126 -0
  61. package/package.json +85 -0
@@ -0,0 +1,2047 @@
1
+ import { state } from "../store-B1sbyexX.js";
2
+ import { createDemoFixtures } from "../demo-data-D8JXWRpw.js";
3
+
4
+ //#region src/testing/mock-behaviors/base.ts
5
+ /**
6
+ * Helper to create a simple state update result
7
+ */
8
+ function simpleStateUpdate(update) {
9
+ return { stateUpdate: update };
10
+ }
11
+ /**
12
+ * Helper to merge attributes safely
13
+ */
14
+ function mergeAttributes(current, updates) {
15
+ return {
16
+ ...current,
17
+ ...updates
18
+ };
19
+ }
20
+
21
+ //#endregion
22
+ //#region src/testing/mock-behaviors/climate.ts
23
+ var ClimateBehavior = class {
24
+ domain = "climate";
25
+ handleService(entity, service, serviceData, _context) {
26
+ switch (service) {
27
+ case "set_temperature": return this.handleSetTemperature(entity, serviceData);
28
+ case "set_hvac_mode": return simpleStateUpdate({ state: serviceData?.hvac_mode ?? entity.state });
29
+ case "set_preset_mode": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { preset_mode: serviceData?.preset_mode }) });
30
+ case "set_fan_mode": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { fan_mode: serviceData?.fan_mode }) });
31
+ case "set_humidity": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { humidity: serviceData?.humidity }) });
32
+ case "set_swing_mode": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { swing_mode: serviceData?.swing_mode }) });
33
+ case "turn_on": return simpleStateUpdate({ state: entity.attributes.last_hvac_mode || "heat" });
34
+ case "turn_off": return simpleStateUpdate({ state: "off" });
35
+ default: return { stateUpdate: null };
36
+ }
37
+ }
38
+ handleSetTemperature(entity, serviceData) {
39
+ const attributeUpdates = {};
40
+ if (serviceData?.temperature !== void 0) attributeUpdates.temperature = serviceData.temperature;
41
+ if (serviceData?.target_temp_high !== void 0) attributeUpdates.target_temp_high = serviceData.target_temp_high;
42
+ if (serviceData?.target_temp_low !== void 0) attributeUpdates.target_temp_low = serviceData.target_temp_low;
43
+ const stateUpdate = { attributes: mergeAttributes(entity.attributes, attributeUpdates) };
44
+ if (serviceData?.hvac_mode !== void 0) stateUpdate.state = serviceData.hvac_mode;
45
+ return simpleStateUpdate(stateUpdate);
46
+ }
47
+ };
48
+
49
+ //#endregion
50
+ //#region src/testing/mock-behaviors/cover.ts
51
+ var CoverBehavior = class {
52
+ domain = "cover";
53
+ handleService(entity, service, serviceData, _context) {
54
+ switch (service) {
55
+ case "open_cover": return simpleStateUpdate({
56
+ state: "open",
57
+ attributes: mergeAttributes(entity.attributes, { current_position: 100 })
58
+ });
59
+ case "close_cover": return simpleStateUpdate({
60
+ state: "closed",
61
+ attributes: mergeAttributes(entity.attributes, { current_position: 0 })
62
+ });
63
+ case "stop_cover": return simpleStateUpdate({ state: entity.state === "opening" || entity.state === "closing" ? "open" : entity.state });
64
+ case "set_cover_position": {
65
+ const position = serviceData?.position ?? 50;
66
+ return simpleStateUpdate({
67
+ state: position === 0 ? "closed" : "open",
68
+ attributes: mergeAttributes(entity.attributes, { current_position: position })
69
+ });
70
+ }
71
+ case "open_cover_tilt": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { current_tilt_position: 100 }) });
72
+ case "close_cover_tilt": return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { current_tilt_position: 0 }) });
73
+ case "set_cover_tilt_position": {
74
+ const tiltPosition = serviceData?.tilt_position ?? 50;
75
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { current_tilt_position: tiltPosition }) });
76
+ }
77
+ case "toggle": return simpleStateUpdate({
78
+ state: entity.state === "closed" ? "open" : "closed",
79
+ attributes: mergeAttributes(entity.attributes, { current_position: entity.state === "closed" ? 100 : 0 })
80
+ });
81
+ default: return { stateUpdate: null };
82
+ }
83
+ }
84
+ };
85
+
86
+ //#endregion
87
+ //#region src/testing/mock-behaviors/light.ts
88
+ var LightBehavior = class {
89
+ domain = "light";
90
+ handleService(entity, service, serviceData, _context) {
91
+ switch (service) {
92
+ case "turn_on": return this.handleTurnOn(entity, serviceData);
93
+ case "turn_off": return this.handleTurnOff(entity, serviceData);
94
+ case "toggle": return this.handleToggle(entity, serviceData);
95
+ default: return { stateUpdate: null };
96
+ }
97
+ }
98
+ handleTurnOn(entity, serviceData) {
99
+ const attributeUpdates = {};
100
+ if (serviceData?.brightness_pct !== void 0) {
101
+ const brightnessPct = Math.max(0, Math.min(100, serviceData.brightness_pct));
102
+ attributeUpdates.brightness = Math.round(brightnessPct / 100 * 255);
103
+ }
104
+ if (serviceData?.brightness_step_pct !== void 0) {
105
+ const currentBrightness = entity.attributes.brightness ?? 255;
106
+ const currentBrightnessPct = currentBrightness / 255 * 100;
107
+ const newBrightnessPct = Math.max(0, Math.min(100, currentBrightnessPct + serviceData.brightness_step_pct));
108
+ attributeUpdates.brightness = Math.round(newBrightnessPct / 100 * 255);
109
+ }
110
+ if (serviceData?.rgb_color !== void 0) {
111
+ let rgbArray = [
112
+ 255,
113
+ 255,
114
+ 255
115
+ ];
116
+ if (typeof serviceData.rgb_color === "string") {
117
+ const parts = serviceData.rgb_color.split(",").map((s) => Number.parseInt(s.trim(), 10));
118
+ if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
119
+ const r = parts[0];
120
+ const g = parts[1];
121
+ const b = parts[2];
122
+ if (r !== void 0 && g !== void 0 && b !== void 0) rgbArray = [
123
+ r,
124
+ g,
125
+ b
126
+ ];
127
+ }
128
+ } else {
129
+ const rgb = serviceData.rgb_color;
130
+ if (Array.isArray(rgb) && rgb.length === 3) {
131
+ const r = rgb[0];
132
+ const g = rgb[1];
133
+ const b = rgb[2];
134
+ if (typeof r === "number" && typeof g === "number" && typeof b === "number" && !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) rgbArray = [
135
+ r,
136
+ g,
137
+ b
138
+ ];
139
+ }
140
+ }
141
+ attributeUpdates.rgb_color = rgbArray;
142
+ attributeUpdates.color_temp = void 0;
143
+ attributeUpdates.color_temp_kelvin = void 0;
144
+ attributeUpdates.hs_color = void 0;
145
+ attributeUpdates.xy_color = void 0;
146
+ }
147
+ if (serviceData?.color_temp_kelvin !== void 0) {
148
+ const kelvin = typeof serviceData.color_temp_kelvin === "string" ? Number.parseInt(serviceData.color_temp_kelvin, 10) : serviceData.color_temp_kelvin;
149
+ if (typeof kelvin === "number" && !Number.isNaN(kelvin)) {
150
+ attributeUpdates.color_temp_kelvin = kelvin;
151
+ attributeUpdates.rgb_color = void 0;
152
+ attributeUpdates.hs_color = void 0;
153
+ attributeUpdates.xy_color = void 0;
154
+ }
155
+ }
156
+ if (serviceData?.transition !== void 0) attributeUpdates.transition = serviceData.transition;
157
+ if (serviceData?.effect !== void 0) attributeUpdates.effect = serviceData.effect;
158
+ return simpleStateUpdate({
159
+ state: "on",
160
+ attributes: mergeAttributes(entity.attributes, attributeUpdates)
161
+ });
162
+ }
163
+ handleTurnOff(entity, serviceData) {
164
+ const attributeUpdates = {};
165
+ if (serviceData?.transition !== void 0) attributeUpdates.transition = serviceData.transition;
166
+ return simpleStateUpdate({
167
+ state: "off",
168
+ attributes: mergeAttributes(entity.attributes, attributeUpdates)
169
+ });
170
+ }
171
+ handleToggle(entity, serviceData) {
172
+ const newState = entity.state === "on" ? "off" : "on";
173
+ const attributeUpdates = {};
174
+ if (newState === "on" && serviceData) {
175
+ if (serviceData.brightness_pct !== void 0) {
176
+ const brightnessPct = Math.max(0, Math.min(100, serviceData.brightness_pct));
177
+ attributeUpdates.brightness = Math.round(brightnessPct / 100 * 255);
178
+ } else if (entity.attributes.brightness) attributeUpdates.brightness = entity.attributes.brightness;
179
+ if (serviceData.rgb_color !== void 0) {
180
+ let rgbArray = [
181
+ 255,
182
+ 255,
183
+ 255
184
+ ];
185
+ if (typeof serviceData.rgb_color === "string") {
186
+ const parts = serviceData.rgb_color.split(",").map((s) => Number.parseInt(s.trim(), 10));
187
+ if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
188
+ const r = parts[0];
189
+ const g = parts[1];
190
+ const b = parts[2];
191
+ if (r !== void 0 && g !== void 0 && b !== void 0) rgbArray = [
192
+ r,
193
+ g,
194
+ b
195
+ ];
196
+ }
197
+ } else {
198
+ const rgb = serviceData.rgb_color;
199
+ if (Array.isArray(rgb) && rgb.length === 3) {
200
+ const r = rgb[0];
201
+ const g = rgb[1];
202
+ const b = rgb[2];
203
+ if (typeof r === "number" && typeof g === "number" && typeof b === "number" && !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) rgbArray = [
204
+ r,
205
+ g,
206
+ b
207
+ ];
208
+ }
209
+ }
210
+ attributeUpdates.rgb_color = rgbArray;
211
+ attributeUpdates.color_temp_kelvin = void 0;
212
+ attributeUpdates.hs_color = void 0;
213
+ attributeUpdates.xy_color = void 0;
214
+ }
215
+ if (serviceData.color_temp_kelvin !== void 0) {
216
+ const kelvin = typeof serviceData.color_temp_kelvin === "string" ? Number.parseInt(serviceData.color_temp_kelvin, 10) : serviceData.color_temp_kelvin;
217
+ if (typeof kelvin === "number" && !Number.isNaN(kelvin)) {
218
+ attributeUpdates.color_temp_kelvin = kelvin;
219
+ attributeUpdates.rgb_color = void 0;
220
+ attributeUpdates.hs_color = void 0;
221
+ attributeUpdates.xy_color = void 0;
222
+ }
223
+ }
224
+ if (serviceData.effect !== void 0) attributeUpdates.effect = serviceData.effect;
225
+ if (serviceData.transition !== void 0) attributeUpdates.transition = serviceData.transition;
226
+ }
227
+ return simpleStateUpdate({
228
+ state: newState,
229
+ attributes: Object.keys(attributeUpdates).length > 0 ? mergeAttributes(entity.attributes, attributeUpdates) : entity.attributes
230
+ });
231
+ }
232
+ };
233
+
234
+ //#endregion
235
+ //#region src/testing/mock-behaviors/lock.ts
236
+ var LockBehavior = class {
237
+ domain = "lock";
238
+ handleService(entity, service, _serviceData, _context) {
239
+ if (entity.state === "unavailable" || entity.state === "unknown") return { stateUpdate: null };
240
+ switch (service) {
241
+ case "lock": return this.handleLock(entity);
242
+ case "unlock": return this.handleUnlock(entity);
243
+ case "open": return this.handleOpen(entity);
244
+ default: return { stateUpdate: null };
245
+ }
246
+ }
247
+ handleLock(entity) {
248
+ if (entity.state === "locked") return { stateUpdate: null };
249
+ if (entity.state === "jammed") return { stateUpdate: null };
250
+ return {
251
+ stateUpdate: { state: "locking" },
252
+ additionalActions: [{
253
+ delay: 500,
254
+ update: { state: "locked" }
255
+ }]
256
+ };
257
+ }
258
+ handleUnlock(entity) {
259
+ if (entity.state === "unlocked") return { stateUpdate: null };
260
+ if (entity.state === "jammed") return { stateUpdate: null };
261
+ return {
262
+ stateUpdate: { state: "unlocking" },
263
+ additionalActions: [{
264
+ delay: 500,
265
+ update: { state: "unlocked" }
266
+ }]
267
+ };
268
+ }
269
+ handleOpen(entity) {
270
+ if (entity.state === "unlocked") return { stateUpdate: null };
271
+ if (entity.state === "jammed") return { stateUpdate: null };
272
+ return {
273
+ stateUpdate: { state: "opening" },
274
+ additionalActions: [{
275
+ delay: 500,
276
+ update: { state: "unlocked" }
277
+ }]
278
+ };
279
+ }
280
+ };
281
+
282
+ //#endregion
283
+ //#region src/testing/mock-behaviors/media_player.ts
284
+ /**
285
+ * Example album covers for mock media players
286
+ * Using placeholder image services that provide nice album art
287
+ */
288
+ const EXAMPLE_ALBUM_COVERS = [
289
+ "https://picsum.photos/seed/album8/300/300",
290
+ "https://picsum.photos/seed/album2/300/300",
291
+ "https://picsum.photos/seed/album3/300/300",
292
+ "https://picsum.photos/seed/album4/300/300",
293
+ "https://picsum.photos/seed/album5/300/300"
294
+ ];
295
+ /**
296
+ * Example tracks for mock media players
297
+ */
298
+ const EXAMPLE_TRACKS = [
299
+ {
300
+ title: "Midnight Horizons",
301
+ artist: "Synthetic Dreams",
302
+ album: "Neon Nights",
303
+ duration: 245
304
+ },
305
+ {
306
+ title: "Electric Pulse",
307
+ artist: "Digital Waves",
308
+ album: "Synth Collection",
309
+ duration: 198
310
+ },
311
+ {
312
+ title: "Cosmic Journey",
313
+ artist: "Space Echo",
314
+ album: "Galaxy Sounds",
315
+ duration: 312
316
+ },
317
+ {
318
+ title: "Urban Nights",
319
+ artist: "City Lights",
320
+ album: "Metropolitan",
321
+ duration: 267
322
+ },
323
+ {
324
+ title: "Ocean Breeze",
325
+ artist: "Coastal Vibes",
326
+ album: "Beach Sessions",
327
+ duration: 189
328
+ }
329
+ ];
330
+ let currentTrackIndex = 0;
331
+ function getNextTrack() {
332
+ const track = EXAMPLE_TRACKS[currentTrackIndex % EXAMPLE_TRACKS.length];
333
+ currentTrackIndex++;
334
+ return track;
335
+ }
336
+ function getCurrentTrack() {
337
+ return EXAMPLE_TRACKS[currentTrackIndex % EXAMPLE_TRACKS.length];
338
+ }
339
+ function getAlbumCover(trackIndex) {
340
+ return EXAMPLE_ALBUM_COVERS[trackIndex % EXAMPLE_ALBUM_COVERS.length];
341
+ }
342
+ var MediaPlayerBehavior = class {
343
+ domain = "media_player";
344
+ handleService(entity, service, serviceData, _context) {
345
+ switch (service) {
346
+ case "media_play": return this.handlePlay(entity);
347
+ case "media_pause": return this.handlePause(entity);
348
+ case "media_play_pause": return this.handlePlayPause(entity);
349
+ case "media_stop": return this.handleStop(entity);
350
+ case "media_next_track": return this.handleNextTrack(entity);
351
+ case "media_previous_track": return this.handlePreviousTrack(entity);
352
+ case "media_seek": return this.handleSeek(entity, serviceData);
353
+ case "volume_up": return this.handleVolumeUp(entity);
354
+ case "volume_down": return this.handleVolumeDown(entity);
355
+ case "set_volume_level": return this.handleSetVolumeLevel(entity, serviceData);
356
+ case "mute_volume": return this.handleMuteVolume(entity, serviceData);
357
+ case "turn_on": return this.handleTurnOn(entity);
358
+ case "turn_off": return this.handleTurnOff(entity);
359
+ case "toggle": return this.handleToggle(entity);
360
+ default: return { stateUpdate: null };
361
+ }
362
+ }
363
+ handlePlay(entity) {
364
+ const attributeUpdates = {};
365
+ if (!entity.attributes.media_title) {
366
+ const track = getCurrentTrack();
367
+ attributeUpdates.media_title = track.title;
368
+ attributeUpdates.media_artist = track.artist;
369
+ attributeUpdates.media_album_name = track.album;
370
+ attributeUpdates.media_duration = track.duration;
371
+ attributeUpdates.media_position = 0;
372
+ attributeUpdates.entity_picture = getAlbumCover(currentTrackIndex - 1);
373
+ }
374
+ if (entity.state === "paused" && entity.attributes.media_position) attributeUpdates.media_position = entity.attributes.media_position;
375
+ return simpleStateUpdate({
376
+ state: "playing",
377
+ attributes: mergeAttributes(entity.attributes, attributeUpdates)
378
+ });
379
+ }
380
+ handlePause(entity) {
381
+ return simpleStateUpdate({
382
+ state: "paused",
383
+ attributes: entity.attributes
384
+ });
385
+ }
386
+ handlePlayPause(entity) {
387
+ if (entity.state === "playing") return this.handlePause(entity);
388
+ return this.handlePlay(entity);
389
+ }
390
+ handleStop(entity) {
391
+ return simpleStateUpdate({
392
+ state: "idle",
393
+ attributes: mergeAttributes(entity.attributes, { media_position: 0 })
394
+ });
395
+ }
396
+ handleNextTrack(entity) {
397
+ const track = getNextTrack();
398
+ const trackIndex = currentTrackIndex - 1;
399
+ return simpleStateUpdate({
400
+ state: entity.state === "playing" ? "playing" : entity.state,
401
+ attributes: mergeAttributes(entity.attributes, {
402
+ media_title: track.title,
403
+ media_artist: track.artist,
404
+ media_album_name: track.album,
405
+ media_duration: track.duration,
406
+ media_position: 0,
407
+ entity_picture: getAlbumCover(trackIndex)
408
+ })
409
+ });
410
+ }
411
+ handlePreviousTrack(entity) {
412
+ currentTrackIndex = Math.max(0, currentTrackIndex - 2);
413
+ const track = getNextTrack();
414
+ const trackIndex = currentTrackIndex - 1;
415
+ return simpleStateUpdate({
416
+ state: entity.state === "playing" ? "playing" : entity.state,
417
+ attributes: mergeAttributes(entity.attributes, {
418
+ media_title: track.title,
419
+ media_artist: track.artist,
420
+ media_album_name: track.album,
421
+ media_duration: track.duration,
422
+ media_position: 0,
423
+ entity_picture: getAlbumCover(trackIndex)
424
+ })
425
+ });
426
+ }
427
+ handleSeek(entity, serviceData) {
428
+ const seekPosition = serviceData?.seek_position;
429
+ if (seekPosition === void 0) return { stateUpdate: null };
430
+ const duration = entity.attributes.media_duration ?? 0;
431
+ const newPosition = Math.max(0, Math.min(duration, seekPosition));
432
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { media_position: newPosition }) });
433
+ }
434
+ handleVolumeUp(entity) {
435
+ const currentVolume = entity.attributes.volume_level ?? 0;
436
+ const newVolume = Math.min(1, currentVolume + .1);
437
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, {
438
+ volume_level: newVolume,
439
+ is_volume_muted: false
440
+ }) });
441
+ }
442
+ handleVolumeDown(entity) {
443
+ const currentVolume = entity.attributes.volume_level ?? 0;
444
+ const newVolume = Math.max(0, currentVolume - .1);
445
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, {
446
+ volume_level: newVolume,
447
+ is_volume_muted: newVolume === 0
448
+ }) });
449
+ }
450
+ handleSetVolumeLevel(entity, serviceData) {
451
+ const volumeLevel = serviceData?.volume_level;
452
+ if (volumeLevel === void 0) return { stateUpdate: null };
453
+ const newVolume = Math.max(0, Math.min(1, volumeLevel));
454
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, {
455
+ volume_level: newVolume,
456
+ is_volume_muted: newVolume === 0
457
+ }) });
458
+ }
459
+ handleMuteVolume(entity, serviceData) {
460
+ const isMuted = serviceData?.is_volume_muted ?? !entity.attributes.is_volume_muted;
461
+ return simpleStateUpdate({ attributes: mergeAttributes(entity.attributes, { is_volume_muted: isMuted }) });
462
+ }
463
+ handleTurnOn(entity) {
464
+ const attributeUpdates = {};
465
+ if (!entity.attributes.media_title) {
466
+ const track = getCurrentTrack();
467
+ attributeUpdates.media_title = track.title;
468
+ attributeUpdates.media_artist = track.artist;
469
+ attributeUpdates.media_album_name = track.album;
470
+ attributeUpdates.media_duration = track.duration;
471
+ attributeUpdates.media_position = 0;
472
+ attributeUpdates.entity_picture = getAlbumCover(currentTrackIndex - 1);
473
+ }
474
+ return simpleStateUpdate({
475
+ state: "playing",
476
+ attributes: mergeAttributes(entity.attributes, attributeUpdates)
477
+ });
478
+ }
479
+ handleTurnOff(entity) {
480
+ return simpleStateUpdate({
481
+ state: "off",
482
+ attributes: mergeAttributes(entity.attributes, { media_position: 0 })
483
+ });
484
+ }
485
+ handleToggle(entity) {
486
+ if (entity.state === "off") return this.handleTurnOn(entity);
487
+ return this.handleTurnOff(entity);
488
+ }
489
+ };
490
+
491
+ //#endregion
492
+ //#region src/testing/mock-behaviors/switch.ts
493
+ var SwitchBehavior = class {
494
+ domain = "switch";
495
+ handleService(entity, service, _serviceData, _context) {
496
+ switch (service) {
497
+ case "turn_on": return simpleStateUpdate({ state: "on" });
498
+ case "turn_off": return simpleStateUpdate({ state: "off" });
499
+ case "toggle": return simpleStateUpdate({ state: entity.state === "on" ? "off" : "on" });
500
+ default: return { stateUpdate: null };
501
+ }
502
+ }
503
+ };
504
+
505
+ //#endregion
506
+ //#region src/testing/mock-behaviors/registry.ts
507
+ /**
508
+ * Central registry of all domain behavior handlers
509
+ */
510
+ var BehaviorRegistry = class {
511
+ handlers = /* @__PURE__ */ new Map();
512
+ constructor() {
513
+ this.registerDefaultHandlers();
514
+ }
515
+ /**
516
+ * Register all built-in domain handlers
517
+ */
518
+ registerDefaultHandlers() {
519
+ this.register(new LightBehavior());
520
+ this.register(new SwitchBehavior());
521
+ this.register(new CoverBehavior());
522
+ this.register(new ClimateBehavior());
523
+ this.register(new MediaPlayerBehavior());
524
+ this.register(new LockBehavior());
525
+ }
526
+ /**
527
+ * Register a domain behavior handler
528
+ */
529
+ register(handler) {
530
+ this.handlers.set(handler.domain, handler);
531
+ }
532
+ /**
533
+ * Get handler for a specific domain
534
+ */
535
+ getHandler(domain) {
536
+ return this.handlers.get(domain);
537
+ }
538
+ /**
539
+ * Check if a domain has a registered handler
540
+ */
541
+ hasHandler(domain) {
542
+ return this.handlers.has(domain);
543
+ }
544
+ /**
545
+ * Get all registered domains
546
+ */
547
+ getRegisteredDomains() {
548
+ return Array.from(this.handlers.keys());
549
+ }
550
+ /**
551
+ * Clear all handlers (useful for testing)
552
+ */
553
+ clear() {
554
+ this.handlers.clear();
555
+ }
556
+ /**
557
+ * Reset to default handlers
558
+ */
559
+ reset() {
560
+ this.clear();
561
+ this.registerDefaultHandlers();
562
+ }
563
+ };
564
+ /**
565
+ * Global behavior registry instance
566
+ */
567
+ const behaviorRegistry = new BehaviorRegistry();
568
+ /**
569
+ * Get behavior handler for a domain
570
+ */
571
+ function getBehaviorHandler(domain) {
572
+ return behaviorRegistry.getHandler(domain);
573
+ }
574
+
575
+ //#endregion
576
+ //#region src/testing/mock.ts
577
+ const ERROR_CODES = {
578
+ UNKNOWN_ERROR: "unknown_error",
579
+ INVALID_FORMAT: "invalid_format",
580
+ NOT_FOUND: "not_found",
581
+ UNAUTHORIZED: "unauthorized",
582
+ HOME_ASSISTANT_ERROR: "home_assistant_error"
583
+ };
584
+ /**
585
+ * Mock WebSocket connection for testing
586
+ *
587
+ * Simulates a Home Assistant WebSocket connection without requiring
588
+ * a real backend.
589
+ *
590
+ * MockConnection does NOT manipulate the store directly.
591
+ * SyncLayer loads fixtures via `getFixtures()` and manages state itself.
592
+ */
593
+ var MockConnection = class {
594
+ connected = false;
595
+ authState = "pending";
596
+ messageHandlers = [];
597
+ eventSubscriptions = /* @__PURE__ */ new Map();
598
+ subscriptions = /* @__PURE__ */ new Map();
599
+ latency;
600
+ fixtures;
601
+ mockVersion = "2024.11.0";
602
+ messageIdCounter = 1;
603
+ autoReact;
604
+ reactionDelay;
605
+ serviceCalls = [];
606
+ sentCommands = [];
607
+ constructor(fixtures, latency = 0, options) {
608
+ this.fixtures = fixtures;
609
+ this.latency = latency;
610
+ this.autoReact = options?.autoReact ?? true;
611
+ this.reactionDelay = options?.reactionDelay ?? 0;
612
+ }
613
+ /**
614
+ * Get connected state
615
+ */
616
+ get connected$() {
617
+ return this.connected;
618
+ }
619
+ /**
620
+ * Simulate connection - sends auth_required per WebSocket spec
621
+ */
622
+ async connect() {
623
+ await this.delay(this.latency);
624
+ this.connected = true;
625
+ this.authState = "pending";
626
+ this.sendAuthRequired();
627
+ }
628
+ /**
629
+ * Register message handler for auth and other server messages
630
+ */
631
+ addEventListener(handler) {
632
+ this.messageHandlers.push(handler);
633
+ return () => {
634
+ const index = this.messageHandlers.indexOf(handler);
635
+ if (index > -1) this.messageHandlers.splice(index, 1);
636
+ };
637
+ }
638
+ /**
639
+ * Handle authentication message (from client)
640
+ */
641
+ async authenticate(_accessToken) {
642
+ if (!this.connected) throw new Error("Not connected");
643
+ if (this.authState !== "pending") throw new Error("Authentication already completed");
644
+ await this.delay(this.latency);
645
+ const isValid = true;
646
+ if (isValid) {
647
+ this.authState = "authenticated";
648
+ const message = {
649
+ type: "auth_ok",
650
+ ha_version: this.mockVersion
651
+ };
652
+ for (const handler of this.messageHandlers) handler(message);
653
+ } else {
654
+ this.authState = "failed";
655
+ const message = {
656
+ type: "auth_invalid",
657
+ message: "Invalid access token"
658
+ };
659
+ for (const handler of this.messageHandlers) handler(message);
660
+ this.disconnect();
661
+ }
662
+ }
663
+ /**
664
+ * Send auth_required message (server-initiated)
665
+ */
666
+ sendAuthRequired() {
667
+ const message = {
668
+ type: "auth_required",
669
+ ha_version: this.mockVersion
670
+ };
671
+ for (const handler of this.messageHandlers) handler(message);
672
+ }
673
+ /**
674
+ * Simulate disconnection
675
+ */
676
+ disconnect() {
677
+ this.connected = false;
678
+ this.authState = "pending";
679
+ this.messageHandlers = [];
680
+ this.eventSubscriptions.clear();
681
+ this.subscriptions.clear();
682
+ }
683
+ /**
684
+ * Close connection (alias for disconnect for compatibility)
685
+ */
686
+ close() {
687
+ this.disconnect();
688
+ }
689
+ /**
690
+ * Send a message (mock implementation)
691
+ */
692
+ async sendMessage(message) {
693
+ if (!this.connected) throw new Error("Not connected");
694
+ await this.delay(this.latency);
695
+ if (message.type === "get_states") this.sendResponse(message.id, Object.values(this.fixtures.entities));
696
+ else if (message.type === "call_service") this.sendResponse(message.id, { success: true });
697
+ }
698
+ /**
699
+ * Call a service (mock implementation)
700
+ */
701
+ async callService(domain, service, serviceData, target) {
702
+ if (!this.connected) throw new Error("Not connected");
703
+ if (this.authState !== "authenticated") throw new Error("Not authenticated");
704
+ this.serviceCalls.push({
705
+ domain,
706
+ service,
707
+ serviceData,
708
+ target,
709
+ timestamp: Date.now()
710
+ });
711
+ return await this.sendMessagePromise({
712
+ type: "call_service",
713
+ domain,
714
+ service,
715
+ service_data: serviceData || {},
716
+ target: target || {}
717
+ });
718
+ }
719
+ /**
720
+ * Send a message and wait for response - returns proper WebSocket result format
721
+ */
722
+ async sendMessagePromise(message) {
723
+ if (!this.connected) throw new Error("Not connected");
724
+ if (this.authState !== "authenticated") throw new Error("Not authenticated");
725
+ if (!message.type || typeof message.type !== "string") throw new Error("Invalid message: type must be a string");
726
+ if (!message.id || typeof message.id !== "number" || message.id < 0) message.id = this.messageIdCounter++;
727
+ this.sentCommands.push({
728
+ type: message.type,
729
+ data: message,
730
+ timestamp: Date.now()
731
+ });
732
+ await this.delay(this.latency);
733
+ try {
734
+ const result = this.handleMessage(message);
735
+ const response = {
736
+ id: message.id,
737
+ type: "result",
738
+ success: true,
739
+ result
740
+ };
741
+ for (const handler of this.messageHandlers) handler(response);
742
+ return result;
743
+ } catch (error) {
744
+ const code = error.code || ERROR_CODES.UNKNOWN_ERROR;
745
+ const errorMessage = error instanceof Error ? error.message : String(error);
746
+ const errorResponse = {
747
+ id: message.id,
748
+ type: "result",
749
+ success: false,
750
+ error: {
751
+ code,
752
+ message: errorMessage
753
+ }
754
+ };
755
+ for (const handler of this.messageHandlers) handler(errorResponse);
756
+ throw error;
757
+ }
758
+ }
759
+ /**
760
+ * Handle different message types and return appropriate data
761
+ */
762
+ handleMessage(message) {
763
+ switch (message.type) {
764
+ case "get_states": return Object.values(this.fixtures.entities);
765
+ case "config/entity_registry/list": return this.fixtures.entityRegistry;
766
+ case "config/entity_registry/get": {
767
+ const entityId = message.entity_id;
768
+ const entry = this.fixtures.entityRegistry.find((e) => e.entity_id === entityId);
769
+ if (!entry) {
770
+ const error = new Error("Entity registry entry not found");
771
+ error.code = ERROR_CODES.NOT_FOUND;
772
+ throw error;
773
+ }
774
+ return entry;
775
+ }
776
+ case "config/device_registry/list": return this.fixtures.deviceRegistry;
777
+ case "config/area_registry/list": return this.fixtures.areaRegistry;
778
+ case "config/floor_registry/list": return this.fixtures.floorRegistry;
779
+ case "config/label_registry/list": return this.fixtures.labelRegistry;
780
+ case "config/entity_registry/update": return this.handleEntityUpdate(message);
781
+ case "call_service": return this.handleServiceCall(message);
782
+ case "subscribe_events": return this.handleSubscribeEvents(message);
783
+ case "unsubscribe_events": return this.handleUnsubscribeEvents(message);
784
+ case "ping": return {};
785
+ case "supported_features": return {};
786
+ case "get_config": return {
787
+ latitude: 0,
788
+ longitude: 0,
789
+ elevation: 0,
790
+ unit_system: {
791
+ length: "km",
792
+ mass: "kg",
793
+ temperature: "°C",
794
+ volume: "L"
795
+ },
796
+ location_name: "Mock Home",
797
+ time_zone: "UTC",
798
+ components: [],
799
+ config_dir: "/config",
800
+ whitelist_external_dirs: [],
801
+ allowlist_external_dirs: [],
802
+ allowlist_external_urls: [],
803
+ version: this.mockVersion,
804
+ config_source: "default_config",
805
+ safe_mode: false,
806
+ state: "RUNNING",
807
+ external_url: null,
808
+ internal_url: null,
809
+ currency: "USD",
810
+ country: null,
811
+ language: "en"
812
+ };
813
+ case "get_services": return {};
814
+ case "fire_event": {
815
+ this.emitEvent({
816
+ event_type: message.event_type,
817
+ data: message.event_data || {},
818
+ origin: "LOCAL",
819
+ time_fired: (/* @__PURE__ */ new Date()).toISOString(),
820
+ context: {
821
+ id: this.generateId(),
822
+ parent_id: null,
823
+ user_id: null
824
+ }
825
+ });
826
+ return {};
827
+ }
828
+ case "history/history_during_period": {
829
+ const entityIds = message.entity_ids || [];
830
+ const result = {};
831
+ if (entityIds.length === 0) return {};
832
+ const startTime = message.start_time ? new Date(message.start_time).getTime() / 1e3 : void 0;
833
+ const endTime = message.end_time ? new Date(message.end_time).getTime() / 1e3 : void 0;
834
+ for (const entityId of entityIds) if (this.fixtures.history && this.fixtures.history[entityId]) {
835
+ let historyData = this.fixtures.history[entityId];
836
+ if (startTime !== void 0 || endTime !== void 0) historyData = historyData.filter((state$1) => {
837
+ if (startTime !== void 0 && state$1.lu < startTime) return false;
838
+ if (endTime !== void 0 && state$1.lu > endTime) return false;
839
+ return true;
840
+ });
841
+ if (historyData.length > 0) result[entityId] = historyData;
842
+ } else {
843
+ const entity = this.fixtures.entities[entityId];
844
+ if (entity) result[entityId] = [{
845
+ s: entity.state,
846
+ lu: Math.floor(Date.now() / 1e3),
847
+ lc: Math.floor(Date.now() / 1e3),
848
+ a: entity.attributes || {}
849
+ }];
850
+ }
851
+ return result;
852
+ }
853
+ case "camera/stream": {
854
+ const entityId = message.entity_id;
855
+ const entity = this.fixtures.entities[entityId];
856
+ if (!entity) {
857
+ const error = new Error(`Camera entity not found: ${entityId}`);
858
+ error.code = ERROR_CODES.NOT_FOUND;
859
+ throw error;
860
+ }
861
+ return { url: `http://homeassistant.local:8123/api/camera_proxy_stream/${entityId}` };
862
+ }
863
+ default: {
864
+ const error = new Error(`Unhandled message type: ${message.type}`);
865
+ error.code = ERROR_CODES.INVALID_FORMAT;
866
+ throw error;
867
+ }
868
+ }
869
+ }
870
+ /**
871
+ * Subscribe to events
872
+ */
873
+ subscribeEvents(callback, eventType) {
874
+ if (!this.connected) return Promise.reject(new Error("Not connected"));
875
+ const handlers = this.eventSubscriptions.get(eventType || "*") || [];
876
+ handlers.push(callback);
877
+ this.eventSubscriptions.set(eventType || "*", handlers);
878
+ return Promise.resolve(() => {
879
+ const handlers$1 = this.eventSubscriptions.get(eventType || "*") || [];
880
+ const index = handlers$1.indexOf(callback);
881
+ if (index > -1) handlers$1.splice(index, 1);
882
+ });
883
+ }
884
+ /**
885
+ * Subscribe to WebSocket messages
886
+ *
887
+ * Used for subscribing to command responses like history/stream
888
+ *
889
+ * @param callback - Callback function for messages
890
+ * @param message - Message to subscribe to
891
+ * @returns Promise resolving to unsubscribe function
892
+ */
893
+ async subscribeMessage(callback, message) {
894
+ if (!this.connected) throw new Error("Not connected");
895
+ if (this.authState !== "authenticated") throw new Error("Not authenticated");
896
+ const subscriptionId = this.messageIdCounter++;
897
+ this.subscriptions.set(subscriptionId, {});
898
+ if (message.type === "subscribe_entities") {
899
+ const additions = {};
900
+ for (const [entityId, entity] of Object.entries(this.fixtures.entities)) additions[entityId] = {
901
+ s: entity.state,
902
+ a: entity.attributes || {},
903
+ c: entity.context || {
904
+ id: "",
905
+ parent_id: null,
906
+ user_id: null
907
+ },
908
+ lc: Math.floor(new Date(entity.last_changed || Date.now()).getTime() / 1e3),
909
+ lu: Math.floor(new Date(entity.last_updated || Date.now()).getTime() / 1e3)
910
+ };
911
+ if (Object.keys(additions).length > 0) callback({ a: additions });
912
+ this.subscriptions.set(subscriptionId, {
913
+ callback,
914
+ isEntitySubscription: true
915
+ });
916
+ return async () => {
917
+ this.subscriptions.delete(subscriptionId);
918
+ };
919
+ }
920
+ if (message.type === "weather/subscribe_forecast") {
921
+ const entityId = message.entity_id;
922
+ const forecastType = message.forecast_type || "hourly";
923
+ if (!entityId) throw new Error("Entity ID required for forecast subscription");
924
+ if (!this.fixtures.entities[entityId]) throw new Error(`Weather entity not found: ${entityId}`);
925
+ const forecast = this.fixtures.forecasts?.[entityId]?.[forecastType] || [];
926
+ setImmediate(() => {
927
+ const forecastEvent = {
928
+ type: forecastType,
929
+ forecast: forecast.length > 0 ? forecast : null
930
+ };
931
+ callback(forecastEvent);
932
+ });
933
+ return async () => {
934
+ this.subscriptions.delete(subscriptionId);
935
+ };
936
+ }
937
+ if (message.type === "history/stream") {
938
+ const entityIds = message.entity_ids || [];
939
+ const startTime = message.start_time;
940
+ const endTime = message.end_time;
941
+ const startTimeSeconds = startTime ? Math.floor(new Date(startTime).getTime() / 1e3) : void 0;
942
+ const endTimeSeconds = endTime ? Math.floor(new Date(endTime).getTime() / 1e3) : void 0;
943
+ const initialHistory = {};
944
+ for (const entityId of entityIds) if (this.fixtures.history && this.fixtures.history[entityId]) {
945
+ let historyData = this.fixtures.history[entityId];
946
+ if (startTimeSeconds !== void 0 || endTimeSeconds !== void 0) historyData = historyData.filter((state$1) => {
947
+ if (startTimeSeconds !== void 0 && state$1.lu < startTimeSeconds) return false;
948
+ if (endTimeSeconds !== void 0 && state$1.lu > endTimeSeconds) return false;
949
+ return true;
950
+ });
951
+ if (historyData.length > 0) initialHistory[entityId] = historyData;
952
+ } else {
953
+ const entity = this.fixtures.entities[entityId];
954
+ if (entity) initialHistory[entityId] = [{
955
+ s: entity.state,
956
+ lu: Math.floor(Date.now() / 1e3),
957
+ lc: Math.floor(Date.now() / 1e3),
958
+ a: entity.attributes || {}
959
+ }];
960
+ }
961
+ await this.delay(this.latency);
962
+ const initialMessage = {
963
+ states: initialHistory,
964
+ start_time: startTimeSeconds,
965
+ end_time: endTimeSeconds
966
+ };
967
+ callback(initialMessage);
968
+ await this.delay(this.latency);
969
+ const emptyMessage = { states: {} };
970
+ callback(emptyMessage);
971
+ } else {
972
+ await this.delay(this.latency);
973
+ const result = this.handleMessage(message);
974
+ callback(result);
975
+ }
976
+ return async () => {
977
+ this.subscriptions.delete(subscriptionId);
978
+ };
979
+ }
980
+ /**
981
+ * Simulate state change
982
+ */
983
+ setState(entityId, updates) {
984
+ const current = this.fixtures.entities[entityId];
985
+ if (!current) throw new Error(`Entity not found: ${entityId}`);
986
+ const newState = {
987
+ ...current,
988
+ ...updates,
989
+ last_changed: (/* @__PURE__ */ new Date()).toISOString(),
990
+ last_updated: (/* @__PURE__ */ new Date()).toISOString()
991
+ };
992
+ this.fixtures.entities[entityId] = newState;
993
+ for (const [, subscription] of this.subscriptions.entries()) if (subscription.callback && subscription.isEntitySubscription) {
994
+ const changeMsg = { c: { [entityId]: { "+": {
995
+ s: newState.state,
996
+ a: newState.attributes || {},
997
+ lc: Math.floor(new Date(newState.last_changed || Date.now()).getTime() / 1e3),
998
+ lu: Math.floor(new Date(newState.last_updated || Date.now()).getTime() / 1e3)
999
+ } } } };
1000
+ subscription.callback(changeMsg);
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Simulate entity registry update
1005
+ */
1006
+ updateEntityRegistry(entityId, updates) {
1007
+ const index = this.fixtures.entityRegistry.findIndex((e) => e.entity_id === entityId);
1008
+ if (index === -1) throw new Error(`Entity registry entry not found: ${entityId}`);
1009
+ const updated = {
1010
+ ...this.fixtures.entityRegistry[index],
1011
+ ...updates
1012
+ };
1013
+ this.fixtures.entityRegistry[index] = updated;
1014
+ this.emitEvent({
1015
+ event_type: "entity_registry_updated",
1016
+ data: {
1017
+ action: "update",
1018
+ entity_id: entityId,
1019
+ changes: updates
1020
+ },
1021
+ origin: "LOCAL",
1022
+ time_fired: (/* @__PURE__ */ new Date()).toISOString(),
1023
+ context: {
1024
+ id: this.generateId(),
1025
+ parent_id: null,
1026
+ user_id: null
1027
+ }
1028
+ });
1029
+ }
1030
+ /**
1031
+ * Simulate area registry update
1032
+ */
1033
+ updateAreaRegistry(areaId, updates) {
1034
+ const index = this.fixtures.areaRegistry.findIndex((a) => a.id === areaId);
1035
+ if (index === -1) throw new Error(`Area registry entry not found: ${areaId}`);
1036
+ const updated = {
1037
+ ...this.fixtures.areaRegistry[index],
1038
+ ...updates
1039
+ };
1040
+ this.fixtures.areaRegistry[index] = updated;
1041
+ this.emitEvent({
1042
+ event_type: "area_registry_updated",
1043
+ data: {
1044
+ action: "update",
1045
+ area_id: areaId,
1046
+ changes: updates
1047
+ },
1048
+ origin: "LOCAL",
1049
+ time_fired: (/* @__PURE__ */ new Date()).toISOString(),
1050
+ context: {
1051
+ id: this.generateId(),
1052
+ parent_id: null,
1053
+ user_id: null
1054
+ }
1055
+ });
1056
+ }
1057
+ /**
1058
+ * Simulate device registry update
1059
+ */
1060
+ updateDeviceRegistry(deviceId, updates) {
1061
+ const index = this.fixtures.deviceRegistry.findIndex((d) => d.id === deviceId);
1062
+ if (index === -1) throw new Error(`Device registry entry not found: ${deviceId}`);
1063
+ const updated = {
1064
+ ...this.fixtures.deviceRegistry[index],
1065
+ ...updates
1066
+ };
1067
+ this.fixtures.deviceRegistry[index] = updated;
1068
+ this.emitEvent({
1069
+ event_type: "device_registry_updated",
1070
+ data: {
1071
+ action: "update",
1072
+ device_id: deviceId,
1073
+ changes: updates
1074
+ },
1075
+ origin: "LOCAL",
1076
+ time_fired: (/* @__PURE__ */ new Date()).toISOString(),
1077
+ context: {
1078
+ id: this.generateId(),
1079
+ parent_id: null,
1080
+ user_id: null
1081
+ }
1082
+ });
1083
+ }
1084
+ /**
1085
+ * Emit custom event - sends proper WebSocket event messages to subscriptions
1086
+ */
1087
+ emitEvent(event) {
1088
+ for (const [subscriptionId, subscription] of this.subscriptions.entries()) if (!subscription.eventType || subscription.eventType === event.event_type) {
1089
+ const eventMessage = {
1090
+ id: subscriptionId,
1091
+ type: "event",
1092
+ event
1093
+ };
1094
+ for (const handler of this.messageHandlers) handler(eventMessage);
1095
+ }
1096
+ const specificHandlers = this.eventSubscriptions.get(event.event_type) || [];
1097
+ for (const handler of specificHandlers) handler(event);
1098
+ const wildcardHandlers = this.eventSubscriptions.get("*") || [];
1099
+ for (const handler of wildcardHandlers) handler(event);
1100
+ }
1101
+ /**
1102
+ * Handle subscribe_events command
1103
+ */
1104
+ handleSubscribeEvents(message) {
1105
+ this.subscriptions.set(message.id, { eventType: message.event_type });
1106
+ return {};
1107
+ }
1108
+ /**
1109
+ * Handle unsubscribe_events command
1110
+ */
1111
+ handleUnsubscribeEvents(message) {
1112
+ const { subscription } = message;
1113
+ if (!this.subscriptions.has(subscription)) {
1114
+ const error = new Error(`Subscription ${subscription} not found`);
1115
+ error.code = ERROR_CODES.NOT_FOUND;
1116
+ throw error;
1117
+ }
1118
+ this.subscriptions.delete(subscription);
1119
+ return {};
1120
+ }
1121
+ /**
1122
+ * Get current fixtures
1123
+ *
1124
+ * SyncLayer calls this to load fixtures into its store.
1125
+ */
1126
+ getFixtures() {
1127
+ return { ...this.fixtures };
1128
+ }
1129
+ /**
1130
+ * Load new fixtures
1131
+ */
1132
+ loadFixtures(fixtures) {
1133
+ this.fixtures = {
1134
+ ...this.fixtures,
1135
+ ...fixtures
1136
+ };
1137
+ }
1138
+ /**
1139
+ * Clear tracking data
1140
+ */
1141
+ clearTracking() {
1142
+ this.serviceCalls = [];
1143
+ this.sentCommands = [];
1144
+ }
1145
+ /**
1146
+ * Get last service call
1147
+ */
1148
+ getLastServiceCall() {
1149
+ return this.serviceCalls[this.serviceCalls.length - 1];
1150
+ }
1151
+ /**
1152
+ * Get last command
1153
+ */
1154
+ getLastCommand() {
1155
+ return this.sentCommands[this.sentCommands.length - 1];
1156
+ }
1157
+ async delay(ms) {
1158
+ if (ms > 0) return new Promise((resolve) => setTimeout(resolve, ms));
1159
+ }
1160
+ sendResponse(id, result) {
1161
+ for (const handler of this.messageHandlers) handler({
1162
+ id,
1163
+ type: "result",
1164
+ success: true,
1165
+ result
1166
+ });
1167
+ }
1168
+ handleEntityUpdate(message) {
1169
+ const entityId = message.entity_id;
1170
+ const updates = { ...message };
1171
+ delete updates.type;
1172
+ delete updates.entity_id;
1173
+ delete updates.id;
1174
+ const index = this.fixtures.entityRegistry.findIndex((e) => e.entity_id === entityId);
1175
+ if (index === -1) {
1176
+ const error = new Error("Entity registry entry not found");
1177
+ error.code = ERROR_CODES.NOT_FOUND;
1178
+ throw error;
1179
+ }
1180
+ this.updateEntityRegistry(entityId, updates);
1181
+ const updated = this.fixtures.entityRegistry[index];
1182
+ if (!updated) throw new Error("Entity registry entry not found after update");
1183
+ return updated;
1184
+ }
1185
+ handleServiceCall(message) {
1186
+ const { domain, service, service_data, target, return_response } = message;
1187
+ const context = {
1188
+ id: this.generateId(),
1189
+ parent_id: null,
1190
+ user_id: null
1191
+ };
1192
+ if (this.autoReact) setTimeout(() => {
1193
+ this.handleServiceReaction(domain, service, service_data, target, context);
1194
+ }, this.reactionDelay);
1195
+ if (domain === "weather" && service === "get_forecasts" && return_response) {
1196
+ const entityId = target?.entity_id;
1197
+ const forecastType = service_data?.type || "hourly";
1198
+ if (!entityId) {
1199
+ const error = new Error("Entity ID required for forecast");
1200
+ error.code = ERROR_CODES.INVALID_FORMAT;
1201
+ throw error;
1202
+ }
1203
+ if (!this.fixtures.entities[entityId]) {
1204
+ const error = new Error(`Weather entity not found: ${entityId}`);
1205
+ error.code = ERROR_CODES.NOT_FOUND;
1206
+ throw error;
1207
+ }
1208
+ const forecast = this.fixtures.forecasts?.[entityId]?.[forecastType] || [];
1209
+ return {
1210
+ context,
1211
+ response: { [entityId]: { forecast } }
1212
+ };
1213
+ }
1214
+ if (return_response) return {
1215
+ context,
1216
+ response: {}
1217
+ };
1218
+ return { context };
1219
+ }
1220
+ /**
1221
+ * Handle automatic reactions to service calls using behavior handlers
1222
+ */
1223
+ handleServiceReaction(domain, service, serviceData, target, context) {
1224
+ const entityIds = this.resolveTargetEntities(domain, target);
1225
+ const handler = getBehaviorHandler(domain);
1226
+ if (!handler) return;
1227
+ for (const entityId of entityIds) {
1228
+ const entity = this.fixtures.entities[entityId];
1229
+ if (!entity) continue;
1230
+ try {
1231
+ const result = handler.handleService(entity, service, serviceData, context);
1232
+ if (result.error) {
1233
+ console.warn(`Mock behavior error for ${entityId}.${service}:`, result.error.message);
1234
+ continue;
1235
+ }
1236
+ if (result.stateUpdate) this.setStateWithContext(entityId, result.stateUpdate, context);
1237
+ if (result.additionalActions) for (const action of result.additionalActions) setTimeout(() => {
1238
+ this.setStateWithContext(entityId, action.update, context);
1239
+ }, action.delay);
1240
+ } catch (error) {
1241
+ console.error(`Mock behavior error for ${entityId}:`, error);
1242
+ }
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Resolve target entities from service call target
1247
+ */
1248
+ resolveTargetEntities(domain, target) {
1249
+ if (!target) return [];
1250
+ const entityIds = [];
1251
+ if (target.entity_id) if (Array.isArray(target.entity_id)) entityIds.push(...target.entity_id);
1252
+ else entityIds.push(target.entity_id);
1253
+ if (target.area_id) {
1254
+ const areaId = target.area_id;
1255
+ for (const entry of this.fixtures.entityRegistry) if (entry.area_id === areaId) {
1256
+ const entity = this.fixtures.entities[entry.entity_id];
1257
+ if (entity && entity.entity_id.startsWith(domain + ".")) entityIds.push(entry.entity_id);
1258
+ }
1259
+ }
1260
+ if (target.device_id) {
1261
+ const deviceId = target.device_id;
1262
+ for (const entry of this.fixtures.entityRegistry) if (entry.device_id === deviceId) {
1263
+ const entity = this.fixtures.entities[entry.entity_id];
1264
+ if (entity && entity.entity_id.startsWith(domain + ".")) entityIds.push(entry.entity_id);
1265
+ }
1266
+ }
1267
+ return entityIds;
1268
+ }
1269
+ /**
1270
+ * Set state with custom context (internal use)
1271
+ */
1272
+ setStateWithContext(entityId, updates, context) {
1273
+ const current = this.fixtures.entities[entityId];
1274
+ if (!current) throw new Error(`Entity not found: ${entityId}`);
1275
+ const newState = {
1276
+ ...current,
1277
+ ...updates,
1278
+ last_changed: updates.state !== current.state ? (/* @__PURE__ */ new Date()).toISOString() : current.last_changed,
1279
+ last_updated: (/* @__PURE__ */ new Date()).toISOString()
1280
+ };
1281
+ this.fixtures.entities[entityId] = newState;
1282
+ for (const [, subscription] of this.subscriptions.entries()) if (subscription.isEntitySubscription && subscription.callback) {
1283
+ const changeMsg = { c: { [entityId]: { "+": {
1284
+ s: newState.state,
1285
+ a: newState.attributes || {},
1286
+ lc: Math.floor(new Date(newState.last_changed || Date.now()).getTime() / 1e3),
1287
+ lu: Math.floor(new Date(newState.last_updated || Date.now()).getTime() / 1e3)
1288
+ } } } };
1289
+ subscription.callback(changeMsg);
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Enable/disable automatic reactions
1294
+ */
1295
+ setAutoReact(enabled) {
1296
+ this.autoReact = enabled;
1297
+ }
1298
+ /**
1299
+ * Set reaction delay (useful for testing timing)
1300
+ */
1301
+ setReactionDelay(delay$1) {
1302
+ this.reactionDelay = delay$1;
1303
+ }
1304
+ generateId() {
1305
+ return `mock_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1306
+ }
1307
+ };
1308
+
1309
+ //#endregion
1310
+ //#region src/testing/fixtures.ts
1311
+ /**
1312
+ * Create a mock entity state
1313
+ *
1314
+ * @param entityId - Entity ID
1315
+ * @param overrides - Optional field overrides
1316
+ * @returns Mock entity state
1317
+ *
1318
+ * @example
1319
+ * ```typescript
1320
+ * const light = createEntity('light.kitchen', {
1321
+ * state: 'on',
1322
+ * attributes: { brightness: 255 },
1323
+ * });
1324
+ * ```
1325
+ */
1326
+ function createEntity(entityId, overrides = {}) {
1327
+ entityId.split(".");
1328
+ return {
1329
+ entity_id: entityId,
1330
+ state: "unknown",
1331
+ attributes: {},
1332
+ last_changed: (/* @__PURE__ */ new Date()).toISOString(),
1333
+ last_updated: (/* @__PURE__ */ new Date()).toISOString(),
1334
+ context: {
1335
+ id: generateId(),
1336
+ parent_id: null,
1337
+ user_id: null
1338
+ },
1339
+ ...overrides
1340
+ };
1341
+ }
1342
+ /**
1343
+ * Create a mock entity registry entry
1344
+ *
1345
+ * @param entityId - Entity ID
1346
+ * @param overrides - Optional field overrides
1347
+ * @returns Mock registry entry
1348
+ *
1349
+ * @example
1350
+ * ```typescript
1351
+ * const entry = createEntityRegistryEntry('light.kitchen', {
1352
+ * name: 'Kitchen Light',
1353
+ * area_id: 'kitchen',
1354
+ * });
1355
+ * ```
1356
+ */
1357
+ function createEntityRegistryEntry(entityId, overrides = {}) {
1358
+ const [, objectId] = entityId.split(".");
1359
+ if (!objectId) throw new Error(`Invalid entity ID: ${entityId}`);
1360
+ return {
1361
+ entity_id: entityId,
1362
+ name: overrides.name || objectId.replace(/_/g, " "),
1363
+ icon: overrides.icon || null,
1364
+ platform: overrides.platform || "mock",
1365
+ area_id: overrides.area_id || null,
1366
+ device_id: overrides.device_id || null,
1367
+ disabled_by: overrides.disabled_by || null,
1368
+ hidden_by: overrides.hidden_by || null,
1369
+ entity_category: overrides.entity_category || null,
1370
+ has_entity_name: overrides.has_entity_name || false,
1371
+ original_name: overrides.original_name || null,
1372
+ unique_id: overrides.unique_id || `mock_${entityId}`,
1373
+ config_entry_id: overrides.config_entry_id || null,
1374
+ labels: overrides.labels || [],
1375
+ translation_key: overrides.translation_key || null,
1376
+ ...overrides
1377
+ };
1378
+ }
1379
+ /**
1380
+ * Create a mock area registry entry
1381
+ *
1382
+ * @param areaId - Area ID
1383
+ * @param overrides - Optional field overrides
1384
+ * @returns Mock area entry
1385
+ *
1386
+ * @example
1387
+ * ```typescript
1388
+ * const area = createArea('kitchen', {
1389
+ * name: 'Kitchen',
1390
+ * floor_id: 'ground_floor',
1391
+ * });
1392
+ * ```
1393
+ */
1394
+ function createArea(areaId, overrides = {}) {
1395
+ return {
1396
+ id: areaId,
1397
+ name: overrides.name || areaId.replace(/_/g, " "),
1398
+ normalized_name: areaId.toLowerCase(),
1399
+ picture: overrides.picture || null,
1400
+ floor_id: overrides.floor_id || null,
1401
+ icon: overrides.icon || null,
1402
+ aliases: overrides.aliases || [],
1403
+ labels: overrides.labels || [],
1404
+ created_at: overrides.created_at || (/* @__PURE__ */ new Date()).toISOString(),
1405
+ modified_at: overrides.modified_at || (/* @__PURE__ */ new Date()).toISOString(),
1406
+ humidity_entity_id: overrides.humidity_entity_id || null,
1407
+ temperature_entity_id: overrides.temperature_entity_id || null,
1408
+ ...overrides
1409
+ };
1410
+ }
1411
+ /**
1412
+ * Create a mock device registry entry
1413
+ *
1414
+ * @param deviceId - Device ID
1415
+ * @param overrides - Optional field overrides
1416
+ * @returns Mock device entry
1417
+ *
1418
+ * @example
1419
+ * ```typescript
1420
+ * const device = createDevice('device_123', {
1421
+ * name: 'Smart Light',
1422
+ * area_id: 'kitchen',
1423
+ * });
1424
+ * ```
1425
+ */
1426
+ function createDevice(deviceId, overrides = {}) {
1427
+ return {
1428
+ id: deviceId,
1429
+ name: overrides.name || "Mock Device",
1430
+ area_id: overrides.area_id || null,
1431
+ name_by_user: overrides.name_by_user || null,
1432
+ disabled_by: overrides.disabled_by || null,
1433
+ configuration_url: overrides.configuration_url || null,
1434
+ manufacturer: overrides.manufacturer || "Mock Manufacturer",
1435
+ model: overrides.model || "Mock Model",
1436
+ sw_version: overrides.sw_version || "1.0.0",
1437
+ hw_version: overrides.hw_version || null,
1438
+ via_device_id: overrides.via_device_id || null,
1439
+ entry_type: overrides.entry_type || null,
1440
+ identifiers: overrides.identifiers || [[deviceId]],
1441
+ connections: overrides.connections || [],
1442
+ config_entries: overrides.config_entries || [],
1443
+ config_entries_subentries: overrides.config_entries_subentries || {},
1444
+ labels: overrides.labels || [],
1445
+ created_at: overrides.created_at || (/* @__PURE__ */ new Date()).toISOString(),
1446
+ modified_at: overrides.modified_at || (/* @__PURE__ */ new Date()).toISOString(),
1447
+ model_id: overrides.model_id || null,
1448
+ primary_config_entry: overrides.primary_config_entry || null,
1449
+ serial_number: overrides.serial_number || null,
1450
+ ...overrides
1451
+ };
1452
+ }
1453
+ /**
1454
+ * Create a mock floor registry entry
1455
+ *
1456
+ * @param floorId - Floor ID
1457
+ * @param overrides - Optional field overrides
1458
+ * @returns Mock floor entry
1459
+ *
1460
+ * @example
1461
+ * ```typescript
1462
+ * const floor = createFloor('ground_floor', {
1463
+ * name: 'Ground Floor',
1464
+ * level: 0,
1465
+ * });
1466
+ * ```
1467
+ */
1468
+ function createFloor(floorId, overrides = {}) {
1469
+ return {
1470
+ floor_id: floorId,
1471
+ name: overrides.name || floorId.replace(/_/g, " "),
1472
+ level: overrides.level || 0,
1473
+ icon: overrides.icon || null,
1474
+ aliases: overrides.aliases || [],
1475
+ ...overrides
1476
+ };
1477
+ }
1478
+ /**
1479
+ * Create a mock label registry entry
1480
+ *
1481
+ * @param labelId - Label ID
1482
+ * @param overrides - Optional field overrides
1483
+ * @returns Mock label entry
1484
+ *
1485
+ * @example
1486
+ * ```typescript
1487
+ * const label = createLabel('important', {
1488
+ * name: 'Important',
1489
+ * color: 'red',
1490
+ * });
1491
+ * ```
1492
+ */
1493
+ function createLabel(labelId, overrides = {}) {
1494
+ return {
1495
+ label_id: labelId,
1496
+ name: overrides.name || labelId.replace(/_/g, " "),
1497
+ color: overrides.color || null,
1498
+ icon: overrides.icon || null,
1499
+ description: overrides.description || null,
1500
+ ...overrides
1501
+ };
1502
+ }
1503
+ /**
1504
+ * Create a basic fixture set for testing
1505
+ *
1506
+ * @returns Complete fixture set with basic entities
1507
+ *
1508
+ * @example
1509
+ * ```typescript
1510
+ * const fixtures = createBasicFixtures();
1511
+ * const mock = initMockHA(fixtures);
1512
+ * ```
1513
+ */
1514
+ function createBasicFixtures() {
1515
+ const kitchen = createArea("kitchen", { name: "Kitchen" });
1516
+ const livingRoom = createArea("living_room", { name: "Living Room" });
1517
+ const bedroom = createArea("bedroom", { name: "Bedroom" });
1518
+ const office = createArea("office", { name: "Office" });
1519
+ const lightDevice = createDevice("device_light_1", {
1520
+ name: "Kitchen Light Strip",
1521
+ area_id: "kitchen"
1522
+ });
1523
+ const entities = {
1524
+ "light.kitchen": createEntity("light.kitchen", {
1525
+ state: "on",
1526
+ attributes: {
1527
+ brightness: 255,
1528
+ friendly_name: "Kitchen Light"
1529
+ }
1530
+ }),
1531
+ "light.living_room": createEntity("light.living_room", {
1532
+ state: "off",
1533
+ attributes: { friendly_name: "Living Room Light" }
1534
+ }),
1535
+ "light.bedroom": createEntity("light.bedroom", {
1536
+ state: "on",
1537
+ attributes: {
1538
+ brightness: 128,
1539
+ color_temp: 370,
1540
+ friendly_name: "Bedroom Light"
1541
+ }
1542
+ }),
1543
+ "switch.fan": createEntity("switch.fan", {
1544
+ state: "off",
1545
+ attributes: { friendly_name: "Ceiling Fan" }
1546
+ }),
1547
+ "switch.outlet": createEntity("switch.outlet", {
1548
+ state: "on",
1549
+ attributes: { friendly_name: "Living Room Outlet" }
1550
+ }),
1551
+ "button.doorbell": createEntity("button.doorbell", {
1552
+ state: "unknown",
1553
+ attributes: { friendly_name: "Doorbell" }
1554
+ }),
1555
+ "button.garage": createEntity("button.garage", {
1556
+ state: "unknown",
1557
+ attributes: { friendly_name: "Garage Door" }
1558
+ }),
1559
+ "cover.garage_door": createEntity("cover.garage_door", {
1560
+ state: "closed",
1561
+ attributes: {
1562
+ friendly_name: "Garage Door",
1563
+ current_position: 0,
1564
+ device_class: "garage"
1565
+ }
1566
+ }),
1567
+ "cover.blind": createEntity("cover.blind", {
1568
+ state: "open",
1569
+ attributes: {
1570
+ friendly_name: "Living Room Blinds",
1571
+ current_position: 75,
1572
+ device_class: "blind"
1573
+ }
1574
+ }),
1575
+ "media_player.living_room": createEntity("media_player.living_room", {
1576
+ state: "playing",
1577
+ attributes: {
1578
+ friendly_name: "Living Room TV",
1579
+ media_title: "Midnight Horizons",
1580
+ media_artist: "Synthetic Dreams",
1581
+ media_album_name: "Neon Nights",
1582
+ entity_picture: "https://picsum.photos/seed/album1/300/300",
1583
+ volume_level: .5,
1584
+ is_volume_muted: false,
1585
+ media_content_type: "music",
1586
+ media_duration: 245,
1587
+ media_position: 45,
1588
+ supported_features: 16383,
1589
+ device_class: "speaker"
1590
+ }
1591
+ }),
1592
+ "media_player.bedroom": createEntity("media_player.bedroom", {
1593
+ state: "paused",
1594
+ attributes: {
1595
+ friendly_name: "Bedroom Speaker",
1596
+ media_title: "Electric Pulse",
1597
+ media_artist: "Digital Waves",
1598
+ media_album_name: "Synth Collection",
1599
+ entity_picture: "https://picsum.photos/seed/album2/300/300",
1600
+ volume_level: .3,
1601
+ is_volume_muted: false,
1602
+ media_content_type: "music",
1603
+ media_duration: 198,
1604
+ media_position: 67,
1605
+ supported_features: 16383,
1606
+ device_class: "speaker"
1607
+ }
1608
+ }),
1609
+ "climate.thermostat": createEntity("climate.thermostat", {
1610
+ state: "heat",
1611
+ attributes: {
1612
+ friendly_name: "Living Room Thermostat",
1613
+ current_temperature: 22,
1614
+ temperature: 23,
1615
+ hvac_modes: [
1616
+ "off",
1617
+ "heat",
1618
+ "cool",
1619
+ "auto"
1620
+ ],
1621
+ hvac_mode: "heat",
1622
+ min_temp: 16,
1623
+ max_temp: 30
1624
+ }
1625
+ }),
1626
+ "climate.bedroom": createEntity("climate.bedroom", {
1627
+ state: "cool",
1628
+ attributes: {
1629
+ friendly_name: "Bedroom AC",
1630
+ current_temperature: 20,
1631
+ temperature: 21,
1632
+ hvac_modes: [
1633
+ "off",
1634
+ "heat",
1635
+ "cool",
1636
+ "auto"
1637
+ ],
1638
+ hvac_mode: "cool",
1639
+ min_temp: 16,
1640
+ max_temp: 30
1641
+ }
1642
+ }),
1643
+ "sensor.temperature": createEntity("sensor.temperature", {
1644
+ state: "22.5",
1645
+ attributes: {
1646
+ unit_of_measurement: "°C",
1647
+ device_class: "temperature",
1648
+ friendly_name: "Temperature Sensor"
1649
+ }
1650
+ }),
1651
+ "sensor.humidity": createEntity("sensor.humidity", {
1652
+ state: "45",
1653
+ attributes: {
1654
+ unit_of_measurement: "%",
1655
+ device_class: "humidity",
1656
+ friendly_name: "Humidity Sensor"
1657
+ }
1658
+ }),
1659
+ "sensor.motion": createEntity("sensor.motion", {
1660
+ state: "on",
1661
+ attributes: {
1662
+ friendly_name: "Motion Sensor",
1663
+ device_class: "motion"
1664
+ }
1665
+ }),
1666
+ "sensor.battery_phone": createEntity("sensor.battery_phone", {
1667
+ state: "85",
1668
+ attributes: {
1669
+ unit_of_measurement: "%",
1670
+ device_class: "battery",
1671
+ friendly_name: "Phone Battery"
1672
+ }
1673
+ }),
1674
+ "sensor.battery_sensor": createEntity("sensor.battery_sensor", {
1675
+ state: "92",
1676
+ attributes: {
1677
+ unit_of_measurement: "%",
1678
+ device_class: "battery",
1679
+ friendly_name: "Motion Sensor Battery"
1680
+ }
1681
+ }),
1682
+ "sensor.battery_laptop": createEntity("sensor.battery_laptop", {
1683
+ state: "78",
1684
+ attributes: {
1685
+ unit_of_measurement: "%",
1686
+ device_class: "battery",
1687
+ friendly_name: "Laptop Battery"
1688
+ }
1689
+ }),
1690
+ "sensor.battery_tablet": createEntity("sensor.battery_tablet", {
1691
+ state: "15",
1692
+ attributes: {
1693
+ unit_of_measurement: "%",
1694
+ device_class: "battery",
1695
+ friendly_name: "Tablet Battery"
1696
+ }
1697
+ }),
1698
+ "sensor.battery_door_sensor": createEntity("sensor.battery_door_sensor", {
1699
+ state: "100",
1700
+ attributes: {
1701
+ unit_of_measurement: "%",
1702
+ device_class: "battery",
1703
+ friendly_name: "Door Sensor Battery"
1704
+ }
1705
+ }),
1706
+ "sensor.battery_window_sensor": createEntity("sensor.battery_window_sensor", {
1707
+ state: "67",
1708
+ attributes: {
1709
+ unit_of_measurement: "%",
1710
+ device_class: "battery",
1711
+ friendly_name: "Window Sensor Battery"
1712
+ }
1713
+ }),
1714
+ "camera.front_door": createEntity("camera.front_door", {
1715
+ state: "idle",
1716
+ attributes: {
1717
+ friendly_name: "Front Door Camera",
1718
+ entity_picture: "/api/camera_proxy/camera.front_door"
1719
+ }
1720
+ }),
1721
+ "weather.home": createEntity("weather.home", {
1722
+ state: "sunny",
1723
+ attributes: {
1724
+ friendly_name: "Home Weather",
1725
+ temperature: 22,
1726
+ temperature_unit: "°C",
1727
+ humidity: 45,
1728
+ pressure: 1013,
1729
+ wind_speed: 5,
1730
+ wind_bearing: 180,
1731
+ condition: "sunny"
1732
+ }
1733
+ }),
1734
+ "scene.movie_night": createEntity("scene.movie_night", {
1735
+ state: "scening",
1736
+ attributes: { friendly_name: "Movie Night" }
1737
+ }),
1738
+ "scene.relax": createEntity("scene.relax", {
1739
+ state: "scening",
1740
+ attributes: { friendly_name: "Relax" }
1741
+ }),
1742
+ "lock.front_door": createEntity("lock.front_door", {
1743
+ state: "locked",
1744
+ attributes: {
1745
+ friendly_name: "Front Door Lock",
1746
+ code_format: "number"
1747
+ }
1748
+ })
1749
+ };
1750
+ const entityRegistry = [
1751
+ createEntityRegistryEntry("light.kitchen", {
1752
+ area_id: "kitchen",
1753
+ device_id: "device_light_1"
1754
+ }),
1755
+ createEntityRegistryEntry("light.living_room", { area_id: "living_room" }),
1756
+ createEntityRegistryEntry("light.bedroom", { area_id: "bedroom" }),
1757
+ createEntityRegistryEntry("switch.fan", { area_id: "bedroom" }),
1758
+ createEntityRegistryEntry("switch.outlet", { area_id: "living_room" }),
1759
+ createEntityRegistryEntry("button.doorbell", { area_id: "kitchen" }),
1760
+ createEntityRegistryEntry("button.garage", { area_id: "kitchen" }),
1761
+ createEntityRegistryEntry("cover.garage_door", { area_id: "kitchen" }),
1762
+ createEntityRegistryEntry("cover.blind", { area_id: "living_room" }),
1763
+ createEntityRegistryEntry("media_player.living_room", { area_id: "living_room" }),
1764
+ createEntityRegistryEntry("media_player.bedroom", { area_id: "bedroom" }),
1765
+ createEntityRegistryEntry("climate.thermostat", { area_id: "living_room" }),
1766
+ createEntityRegistryEntry("climate.bedroom", { area_id: "bedroom" }),
1767
+ createEntityRegistryEntry("sensor.temperature", { area_id: "kitchen" }),
1768
+ createEntityRegistryEntry("sensor.humidity", { area_id: "kitchen" }),
1769
+ createEntityRegistryEntry("sensor.motion", { area_id: "living_room" }),
1770
+ createEntityRegistryEntry("sensor.battery_phone", { area_id: "bedroom" }),
1771
+ createEntityRegistryEntry("sensor.battery_sensor", { area_id: "living_room" }),
1772
+ createEntityRegistryEntry("sensor.battery_laptop", { area_id: "office" }),
1773
+ createEntityRegistryEntry("sensor.battery_tablet", { area_id: "living_room" }),
1774
+ createEntityRegistryEntry("sensor.battery_door_sensor", { area_id: "kitchen" }),
1775
+ createEntityRegistryEntry("sensor.battery_window_sensor", { area_id: "bedroom" }),
1776
+ createEntityRegistryEntry("camera.front_door", { area_id: "kitchen" }),
1777
+ createEntityRegistryEntry("weather.home", { area_id: null }),
1778
+ createEntityRegistryEntry("scene.movie_night", { area_id: "living_room" }),
1779
+ createEntityRegistryEntry("scene.relax", { area_id: "bedroom" }),
1780
+ createEntityRegistryEntry("lock.front_door", { area_id: "kitchen" })
1781
+ ];
1782
+ return {
1783
+ entities,
1784
+ entityRegistry,
1785
+ deviceRegistry: [lightDevice],
1786
+ areaRegistry: [
1787
+ kitchen,
1788
+ livingRoom,
1789
+ bedroom,
1790
+ office
1791
+ ],
1792
+ floorRegistry: [],
1793
+ labelRegistry: []
1794
+ };
1795
+ }
1796
+ /**
1797
+ * Load fixtures from JSON
1798
+ *
1799
+ * @param json - JSON string or object
1800
+ * @returns Parsed fixtures
1801
+ *
1802
+ * @example
1803
+ * ```typescript
1804
+ * const fixtures = loadFixture(`{
1805
+ * "entities": { ... },
1806
+ * "entityRegistry": [ ... ]
1807
+ * }`);
1808
+ * ```
1809
+ */
1810
+ function loadFixture(json) {
1811
+ if (typeof json === "string") return JSON.parse(json);
1812
+ return json;
1813
+ }
1814
+ /**
1815
+ * Create a fixture set from partial data
1816
+ *
1817
+ * Fills in missing fields with defaults.
1818
+ *
1819
+ * @param partial - Partial fixture data
1820
+ * @returns Complete fixture set
1821
+ */
1822
+ function createFixture(partial = {}) {
1823
+ return {
1824
+ entities: partial.entities || {},
1825
+ entityRegistry: partial.entityRegistry || [],
1826
+ deviceRegistry: partial.deviceRegistry || [],
1827
+ areaRegistry: partial.areaRegistry || [],
1828
+ floorRegistry: partial.floorRegistry || [],
1829
+ labelRegistry: partial.labelRegistry || [],
1830
+ history: partial.history || {}
1831
+ };
1832
+ }
1833
+ /**
1834
+ * Create a history state point
1835
+ *
1836
+ * @param state - State value
1837
+ * @param timestamp - Unix timestamp in seconds
1838
+ * @param attributes - Optional attributes
1839
+ * @param lastChanged - Optional last changed timestamp (defaults to timestamp)
1840
+ * @returns History state point
1841
+ *
1842
+ * @example
1843
+ * ```typescript
1844
+ * const historyState = createHistoryState("20.5", Math.floor(Date.now() / 1000) - 3600, {
1845
+ * unit_of_measurement: "°C",
1846
+ * });
1847
+ * ```
1848
+ */
1849
+ function createHistoryState(state$1, timestamp, attributes = {}, lastChanged) {
1850
+ return {
1851
+ s: state$1,
1852
+ lu: timestamp,
1853
+ lc: lastChanged ?? timestamp,
1854
+ a: attributes
1855
+ };
1856
+ }
1857
+ /**
1858
+ * Generate a random ID
1859
+ */
1860
+ function generateId() {
1861
+ return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1862
+ }
1863
+
1864
+ //#endregion
1865
+ //#region src/testing/helpers.ts
1866
+ /**
1867
+ * Wait for a condition to be true
1868
+ *
1869
+ * @param condition - Function that returns true when ready
1870
+ * @param timeout - Maximum wait time in ms
1871
+ * @param interval - Check interval in ms
1872
+ * @returns Promise that resolves when condition is true
1873
+ *
1874
+ * @example
1875
+ * ```typescript
1876
+ * await waitFor(() => entity('light.kitchen').get()?.state === 'on', 1000);
1877
+ * ```
1878
+ */
1879
+ async function waitFor(condition, timeout = 1e3, interval = 1) {
1880
+ const start = Date.now();
1881
+ while (!condition()) {
1882
+ if (Date.now() - start > timeout) throw new Error(`Timeout waiting for condition after ${timeout}ms`);
1883
+ await delay(interval);
1884
+ }
1885
+ }
1886
+ /**
1887
+ * Simulate state change event
1888
+ *
1889
+ * Updates entity state and emits event through mock connection.
1890
+ *
1891
+ * @param mockConnection - MockConnection instance
1892
+ * @param entityId - Entity ID
1893
+ * @param updates - State updates
1894
+ * @param options - Optional simulation options
1895
+ * @returns Promise that resolves when update completes
1896
+ *
1897
+ * @example
1898
+ * ```typescript
1899
+ * const mockConnection = new MockConnection(fixtures);
1900
+ * await simulateStateChange(mockConnection, 'light.kitchen', {
1901
+ * state: 'on',
1902
+ * attributes: { brightness: 255 },
1903
+ * });
1904
+ * ```
1905
+ */
1906
+ async function simulateStateChange(mockConnection, entityId, updates, options) {
1907
+ mockConnection.setState(entityId, updates);
1908
+ await delay(options?.delay || 10);
1909
+ }
1910
+ /**
1911
+ * Simulate registry update event
1912
+ *
1913
+ * Updates entity registry and emits event through mock connection.
1914
+ *
1915
+ * @param mockConnection - MockConnection instance
1916
+ * @param entityId - Entity ID
1917
+ * @param updates - Registry updates
1918
+ * @param options - Optional simulation options
1919
+ * @returns Promise that resolves when update completes
1920
+ *
1921
+ * @example
1922
+ * ```typescript
1923
+ * const mockConnection = new MockConnection(fixtures);
1924
+ * await simulateRegistryUpdate(mockConnection, 'light.kitchen', {
1925
+ * name: 'New Name',
1926
+ * area_id: 'bedroom',
1927
+ * });
1928
+ * ```
1929
+ */
1930
+ async function simulateRegistryUpdate(mockConnection, entityId, updates, options) {
1931
+ mockConnection.updateEntityRegistry(entityId, updates);
1932
+ await delay(options?.delay || 10);
1933
+ }
1934
+ /**
1935
+ * Simulate multiple state changes in sequence
1936
+ *
1937
+ * @param mockConnection - MockConnection instance
1938
+ * @param changes - Array of state change configurations
1939
+ * @param delayBetween - Delay between changes in ms
1940
+ * @returns Promise that resolves when all changes complete
1941
+ *
1942
+ * @example
1943
+ * ```typescript
1944
+ * const mockConnection = new MockConnection(fixtures);
1945
+ * await simulateStateChanges(mockConnection, [
1946
+ * { entityId: 'light.kitchen', updates: { state: 'on' } },
1947
+ * { entityId: 'light.living_room', updates: { state: 'on' } },
1948
+ * ], 100);
1949
+ * ```
1950
+ */
1951
+ async function simulateStateChanges(mockConnection, changes, delayBetween = 0) {
1952
+ for (const change of changes) {
1953
+ await simulateStateChange(mockConnection, change.entityId, change.updates);
1954
+ if (delayBetween > 0) await delay(delayBetween);
1955
+ }
1956
+ }
1957
+ /**
1958
+ * Get current store state snapshot
1959
+ *
1960
+ * @returns Current store state
1961
+ */
1962
+ function getStoreSnapshot() {
1963
+ return state;
1964
+ }
1965
+ /**
1966
+ * Get entity from store
1967
+ *
1968
+ * @param entityId - Entity ID
1969
+ * @returns Entity state or undefined
1970
+ */
1971
+ function getEntityFromStore(entityId) {
1972
+ return state.entities[entityId];
1973
+ }
1974
+ /**
1975
+ * Get entity registry entry from store
1976
+ *
1977
+ * @param entityId - Entity ID
1978
+ * @returns Registry entry or undefined
1979
+ */
1980
+ function getEntityRegistryFromStore(entityId) {
1981
+ return state.entityRegistry[entityId];
1982
+ }
1983
+ /**
1984
+ * Check if entity exists in store
1985
+ *
1986
+ * @param entityId - Entity ID
1987
+ * @returns True if entity exists
1988
+ */
1989
+ function entityExistsInStore(entityId) {
1990
+ return entityId in state.entities;
1991
+ }
1992
+ /**
1993
+ * Delay for specified time
1994
+ *
1995
+ * @param ms - Milliseconds to delay
1996
+ * @returns Promise that resolves after delay
1997
+ */
1998
+ function delay(ms) {
1999
+ return new Promise((resolve) => setTimeout(resolve, ms));
2000
+ }
2001
+ /**
2002
+ * Wait for mock connection to complete
2003
+ *
2004
+ * Helper to ensure fixtures are loaded before running tests.
2005
+ * Use after creating MockConnection and connecting.
2006
+ *
2007
+ * @example
2008
+ * ```typescript
2009
+ * const mockConnection = new MockConnection(fixtures);
2010
+ * await mockConnection.connect();
2011
+ * await waitForConnection();
2012
+ * // Now safe to run queries
2013
+ * ```
2014
+ */
2015
+ function waitForConnection() {
2016
+ return delay(10);
2017
+ }
2018
+ /**
2019
+ * Create a spy function for testing
2020
+ *
2021
+ * @returns Spy function with call tracking
2022
+ *
2023
+ * @example
2024
+ * ```typescript
2025
+ * const spy = createSpy();
2026
+ * entities().byDomain('light').subscribe(spy);
2027
+ * // Check spy.calls, spy.callCount, etc.
2028
+ * ```
2029
+ */
2030
+ function createSpy() {
2031
+ const calls = [];
2032
+ const spy = (...args) => {
2033
+ calls.push(args);
2034
+ };
2035
+ spy.calls = calls;
2036
+ spy.reset = () => {
2037
+ calls.length = 0;
2038
+ };
2039
+ Object.defineProperty(spy, "callCount", {
2040
+ get: () => calls.length,
2041
+ configurable: true
2042
+ });
2043
+ return spy;
2044
+ }
2045
+
2046
+ //#endregion
2047
+ export { MockConnection, createArea, createBasicFixtures, createDemoFixtures, createDevice, createEntity, createEntityRegistryEntry, createFixture, createFloor, createHistoryState, createLabel, createSpy, delay, entityExistsInStore, getEntityFromStore, getEntityRegistryFromStore, getStoreSnapshot, loadFixture, simulateRegistryUpdate, simulateStateChange, simulateStateChanges, waitFor, waitForConnection };