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