homebridge-sonos-scenes 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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/config.schema.json +86 -0
- package/dist/src/accessories/sceneSwitch.d.ts +17 -0
- package/dist/src/accessories/sceneSwitch.js +81 -0
- package/dist/src/accessories/sceneSwitch.js.map +1 -0
- package/dist/src/config.d.ts +7 -0
- package/dist/src/config.js +256 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/discoveryService.d.ts +10 -0
- package/dist/src/discoveryService.js +32 -0
- package/dist/src/discoveryService.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/logger.d.ts +23 -0
- package/dist/src/logger.js +70 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/platform.d.ts +23 -0
- package/dist/src/platform.js +111 -0
- package/dist/src/platform.js.map +1 -0
- package/dist/src/sampleTopology.d.ts +2 -0
- package/dist/src/sampleTopology.js +66 -0
- package/dist/src/sampleTopology.js.map +1 -0
- package/dist/src/sceneRunner.d.ts +20 -0
- package/dist/src/sceneRunner.js +186 -0
- package/dist/src/sceneRunner.js.map +1 -0
- package/dist/src/transports/index.d.ts +2 -0
- package/dist/src/transports/index.js +13 -0
- package/dist/src/transports/index.js.map +1 -0
- package/dist/src/transports/localTransport.d.ts +33 -0
- package/dist/src/transports/localTransport.js +544 -0
- package/dist/src/transports/localTransport.js.map +1 -0
- package/dist/src/types.d.ts +141 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/ui/serverApi.d.ts +11 -0
- package/dist/src/ui/serverApi.js +52 -0
- package/dist/src/ui/serverApi.js.map +1 -0
- package/examples/config.example.json +48 -0
- package/examples/sample-topology.json +77 -0
- package/homebridge-ui/public/index.html +810 -0
- package/homebridge-ui/server.js +67 -0
- package/package.json +52 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
:root {
|
|
3
|
+
--scene-bg: linear-gradient(135deg, rgba(16, 24, 40, 0.04), rgba(10, 132, 255, 0.08));
|
|
4
|
+
--scene-accent: #0a84ff;
|
|
5
|
+
--scene-ink: #162033;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.scene-shell {
|
|
9
|
+
display: grid;
|
|
10
|
+
gap: 1rem;
|
|
11
|
+
color: var(--scene-ink);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.scene-hero {
|
|
15
|
+
border: 1px solid rgba(10, 132, 255, 0.18);
|
|
16
|
+
border-radius: 1rem;
|
|
17
|
+
padding: 1.25rem;
|
|
18
|
+
background: var(--scene-bg);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.scene-grid {
|
|
22
|
+
display: grid;
|
|
23
|
+
gap: 1rem;
|
|
24
|
+
grid-template-columns: minmax(280px, 1fr) minmax(360px, 2fr);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.scene-card {
|
|
28
|
+
border-radius: 1rem;
|
|
29
|
+
border: 1px solid rgba(22, 32, 51, 0.08);
|
|
30
|
+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.scene-card .card-header {
|
|
34
|
+
border-bottom: 1px solid rgba(22, 32, 51, 0.08);
|
|
35
|
+
background: rgba(255, 255, 255, 0.65);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.scene-list button {
|
|
39
|
+
width: 100%;
|
|
40
|
+
text-align: left;
|
|
41
|
+
border-radius: 0.85rem;
|
|
42
|
+
padding: 0.75rem 0.9rem;
|
|
43
|
+
border: 1px solid rgba(22, 32, 51, 0.08);
|
|
44
|
+
background: white;
|
|
45
|
+
margin-bottom: 0.5rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.scene-list button.active {
|
|
49
|
+
border-color: rgba(10, 132, 255, 0.45);
|
|
50
|
+
background: rgba(10, 132, 255, 0.08);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.scene-badge {
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: 0.35rem;
|
|
57
|
+
border-radius: 999px;
|
|
58
|
+
padding: 0.25rem 0.6rem;
|
|
59
|
+
font-size: 0.8rem;
|
|
60
|
+
background: rgba(10, 132, 255, 0.08);
|
|
61
|
+
color: var(--scene-accent);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.scene-member-grid {
|
|
65
|
+
display: grid;
|
|
66
|
+
gap: 0.5rem;
|
|
67
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.scene-member-pill {
|
|
71
|
+
border: 1px solid rgba(22, 32, 51, 0.08);
|
|
72
|
+
border-radius: 0.85rem;
|
|
73
|
+
padding: 0.55rem 0.75rem;
|
|
74
|
+
background: white;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.scene-log {
|
|
78
|
+
max-height: 280px;
|
|
79
|
+
overflow: auto;
|
|
80
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
81
|
+
font-size: 0.86rem;
|
|
82
|
+
background: #101828;
|
|
83
|
+
color: #d2def0;
|
|
84
|
+
border-radius: 0.85rem;
|
|
85
|
+
padding: 0.85rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.scene-log-line {
|
|
89
|
+
margin-bottom: 0.35rem;
|
|
90
|
+
white-space: pre-wrap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.scene-discovery-grid {
|
|
94
|
+
display: grid;
|
|
95
|
+
gap: 1rem;
|
|
96
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.scene-help {
|
|
100
|
+
color: #667085;
|
|
101
|
+
font-size: 0.92rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@media (max-width: 960px) {
|
|
105
|
+
.scene-grid {
|
|
106
|
+
grid-template-columns: 1fr;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
110
|
+
|
|
111
|
+
<div class="scene-shell">
|
|
112
|
+
<section class="scene-hero">
|
|
113
|
+
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
|
|
114
|
+
<div>
|
|
115
|
+
<div class="scene-badge mb-2">Sonos Scenes</div>
|
|
116
|
+
<h2 class="h4 mb-2">Discovery-driven Sonos workflow builder</h2>
|
|
117
|
+
<p class="mb-0 scene-help">
|
|
118
|
+
Discover current rooms and groups, build switch-triggerable scenes, validate them against the live topology,
|
|
119
|
+
and test them before saving.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="d-flex flex-wrap gap-2">
|
|
123
|
+
<button class="btn btn-outline-secondary" id="refresh-button" type="button">Discover</button>
|
|
124
|
+
<button class="btn btn-outline-primary" id="validate-button" type="button">Validate</button>
|
|
125
|
+
<button class="btn btn-primary" id="test-button" type="button">Run Test</button>
|
|
126
|
+
<button class="btn btn-success" id="save-homebridge-button" type="button">Save To Homebridge</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
|
|
131
|
+
<div class="scene-grid">
|
|
132
|
+
<section class="card scene-card">
|
|
133
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
|
134
|
+
<div>
|
|
135
|
+
<strong>Scenes</strong>
|
|
136
|
+
<div class="scene-help">Each saved scene becomes a HomeKit switch accessory.</div>
|
|
137
|
+
</div>
|
|
138
|
+
<button class="btn btn-sm btn-outline-primary" id="new-scene-button" type="button">New Scene</button>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="card-body">
|
|
141
|
+
<div id="scene-list" class="scene-list"></div>
|
|
142
|
+
</div>
|
|
143
|
+
</section>
|
|
144
|
+
|
|
145
|
+
<section class="card scene-card">
|
|
146
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
|
147
|
+
<div>
|
|
148
|
+
<strong>Scene Editor</strong>
|
|
149
|
+
<div class="scene-help">Stable Sonos IDs are stored under the hood; the UI keeps room selection friendly.</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="d-flex gap-2">
|
|
152
|
+
<button class="btn btn-sm btn-outline-danger" id="delete-scene-button" type="button">Delete</button>
|
|
153
|
+
<button class="btn btn-sm btn-primary" id="save-scene-button" type="button">Save Scene</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="card-body">
|
|
157
|
+
<div class="row g-3">
|
|
158
|
+
<div class="col-md-6">
|
|
159
|
+
<label class="form-label" for="scene-name">Scene Name</label>
|
|
160
|
+
<input class="form-control" id="scene-name" type="text">
|
|
161
|
+
</div>
|
|
162
|
+
<div class="col-md-6">
|
|
163
|
+
<label class="form-label" for="scene-id">Scene ID</label>
|
|
164
|
+
<input class="form-control" id="scene-id" type="text" readonly>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="col-md-6">
|
|
167
|
+
<label class="form-label" for="household-select">Household</label>
|
|
168
|
+
<select class="form-select" id="household-select"></select>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="col-md-6">
|
|
171
|
+
<label class="form-label" for="coordinator-select">Coordinator Room</label>
|
|
172
|
+
<select class="form-select" id="coordinator-select"></select>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="col-12">
|
|
175
|
+
<label class="form-label">Group Members</label>
|
|
176
|
+
<div id="member-list" class="scene-member-grid"></div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="col-md-4">
|
|
179
|
+
<label class="form-label" for="source-kind">Source Kind</label>
|
|
180
|
+
<select class="form-select" id="source-kind"></select>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="col-md-8">
|
|
183
|
+
<label class="form-label" for="source-target">Source Target</label>
|
|
184
|
+
<select class="form-select" id="source-target"></select>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="col-md-3">
|
|
187
|
+
<label class="form-label" for="coordinator-volume">Coordinator Volume</label>
|
|
188
|
+
<input class="form-control" id="coordinator-volume" type="number" min="0" max="100">
|
|
189
|
+
</div>
|
|
190
|
+
<div class="col-md-3">
|
|
191
|
+
<label class="form-label" for="settle-ms">Settle Delay (ms)</label>
|
|
192
|
+
<input class="form-control" id="settle-ms" type="number" min="0">
|
|
193
|
+
</div>
|
|
194
|
+
<div class="col-md-3">
|
|
195
|
+
<label class="form-label" for="retry-count">Retry Count</label>
|
|
196
|
+
<input class="form-control" id="retry-count" type="number" min="0">
|
|
197
|
+
</div>
|
|
198
|
+
<div class="col-md-3">
|
|
199
|
+
<label class="form-label" for="retry-delay-ms">Retry Delay (ms)</label>
|
|
200
|
+
<input class="form-control" id="retry-delay-ms" type="number" min="0">
|
|
201
|
+
</div>
|
|
202
|
+
<div class="col-md-4">
|
|
203
|
+
<label class="form-label" for="auto-reset-ms">Auto Reset (ms)</label>
|
|
204
|
+
<input class="form-control" id="auto-reset-ms" type="number" min="0">
|
|
205
|
+
</div>
|
|
206
|
+
<div class="col-md-4">
|
|
207
|
+
<label class="form-label" for="off-behavior">Off Behavior</label>
|
|
208
|
+
<select class="form-select" id="off-behavior">
|
|
209
|
+
<option value="none">None</option>
|
|
210
|
+
<option value="ungroup">Ungroup</option>
|
|
211
|
+
</select>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="col-12">
|
|
214
|
+
<label class="form-label">Per-room Volumes</label>
|
|
215
|
+
<div id="volume-list" class="scene-member-grid"></div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</section>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<section class="card scene-card">
|
|
223
|
+
<div class="card-header">
|
|
224
|
+
<strong>Discovery Snapshot</strong>
|
|
225
|
+
<div class="scene-help">The editor updates its available coordinators, members, favorites, and inputs from this topology.</div>
|
|
226
|
+
</div>
|
|
227
|
+
<div class="card-body">
|
|
228
|
+
<div id="discovery-summary" class="mb-3 scene-help"></div>
|
|
229
|
+
<div id="discovery-grid" class="scene-discovery-grid"></div>
|
|
230
|
+
</div>
|
|
231
|
+
</section>
|
|
232
|
+
|
|
233
|
+
<section class="card scene-card">
|
|
234
|
+
<div class="card-header">
|
|
235
|
+
<strong>Validation And Logs</strong>
|
|
236
|
+
<div class="scene-help">Validation uses the same transport and scene runner as the plugin runtime.</div>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="card-body">
|
|
239
|
+
<div id="validation-output" class="mb-3"></div>
|
|
240
|
+
<div id="log-output" class="scene-log"></div>
|
|
241
|
+
</div>
|
|
242
|
+
</section>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<script>
|
|
246
|
+
(() => {
|
|
247
|
+
const state = {
|
|
248
|
+
config: null,
|
|
249
|
+
topology: null,
|
|
250
|
+
draft: null,
|
|
251
|
+
activeSceneId: null,
|
|
252
|
+
validation: null,
|
|
253
|
+
lastRun: null,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const elements = {
|
|
257
|
+
sceneList: document.getElementById("scene-list"),
|
|
258
|
+
sceneName: document.getElementById("scene-name"),
|
|
259
|
+
sceneId: document.getElementById("scene-id"),
|
|
260
|
+
householdSelect: document.getElementById("household-select"),
|
|
261
|
+
coordinatorSelect: document.getElementById("coordinator-select"),
|
|
262
|
+
memberList: document.getElementById("member-list"),
|
|
263
|
+
sourceKind: document.getElementById("source-kind"),
|
|
264
|
+
sourceTarget: document.getElementById("source-target"),
|
|
265
|
+
coordinatorVolume: document.getElementById("coordinator-volume"),
|
|
266
|
+
settleMs: document.getElementById("settle-ms"),
|
|
267
|
+
retryCount: document.getElementById("retry-count"),
|
|
268
|
+
retryDelayMs: document.getElementById("retry-delay-ms"),
|
|
269
|
+
autoResetMs: document.getElementById("auto-reset-ms"),
|
|
270
|
+
offBehavior: document.getElementById("off-behavior"),
|
|
271
|
+
volumeList: document.getElementById("volume-list"),
|
|
272
|
+
validationOutput: document.getElementById("validation-output"),
|
|
273
|
+
logOutput: document.getElementById("log-output"),
|
|
274
|
+
discoverySummary: document.getElementById("discovery-summary"),
|
|
275
|
+
discoveryGrid: document.getElementById("discovery-grid"),
|
|
276
|
+
refreshButton: document.getElementById("refresh-button"),
|
|
277
|
+
validateButton: document.getElementById("validate-button"),
|
|
278
|
+
testButton: document.getElementById("test-button"),
|
|
279
|
+
saveHomebridgeButton: document.getElementById("save-homebridge-button"),
|
|
280
|
+
saveSceneButton: document.getElementById("save-scene-button"),
|
|
281
|
+
deleteSceneButton: document.getElementById("delete-scene-button"),
|
|
282
|
+
newSceneButton: document.getElementById("new-scene-button"),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
function randomId() {
|
|
286
|
+
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
287
|
+
return window.crypto.randomUUID();
|
|
288
|
+
}
|
|
289
|
+
return `scene-${Date.now()}-${Math.round(Math.random() * 1000)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clone(value) {
|
|
293
|
+
return JSON.parse(JSON.stringify(value));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function normalizeConfig(config) {
|
|
297
|
+
const safeConfig = config || {};
|
|
298
|
+
return {
|
|
299
|
+
platform: "SonosScenes",
|
|
300
|
+
name: safeConfig.name || "Sonos Scenes",
|
|
301
|
+
logLevel: safeConfig.logLevel || "info",
|
|
302
|
+
defaultHouseholdId: safeConfig.defaultHouseholdId || "",
|
|
303
|
+
transport: Object.assign(
|
|
304
|
+
{
|
|
305
|
+
kind: "local",
|
|
306
|
+
enableLiveDiscovery: true,
|
|
307
|
+
discoveryTimeoutMs: 2500,
|
|
308
|
+
requestTimeoutMs: 5000,
|
|
309
|
+
allowTvSource: false,
|
|
310
|
+
},
|
|
311
|
+
safeConfig.transport || {},
|
|
312
|
+
),
|
|
313
|
+
scenes: Array.isArray(safeConfig.scenes) ? safeConfig.scenes.map(normalizeScene) : [],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeScene(scene) {
|
|
318
|
+
const safeScene = scene || {};
|
|
319
|
+
return {
|
|
320
|
+
id: safeScene.id || randomId(),
|
|
321
|
+
name: safeScene.name || "New Scene",
|
|
322
|
+
householdId: safeScene.householdId || "",
|
|
323
|
+
coordinatorPlayerId: safeScene.coordinatorPlayerId || "",
|
|
324
|
+
memberPlayerIds: Array.isArray(safeScene.memberPlayerIds) ? Array.from(new Set(safeScene.memberPlayerIds)) : [],
|
|
325
|
+
source: safeScene.source || { kind: "favorite", favoriteId: "" },
|
|
326
|
+
coordinatorVolume:
|
|
327
|
+
safeScene.coordinatorVolume === undefined || safeScene.coordinatorVolume === null
|
|
328
|
+
? ""
|
|
329
|
+
: safeScene.coordinatorVolume,
|
|
330
|
+
playerVolumes: Array.isArray(safeScene.playerVolumes) ? safeScene.playerVolumes : [],
|
|
331
|
+
offBehavior: safeScene.offBehavior || { kind: "none" },
|
|
332
|
+
settleMs: safeScene.settleMs ?? 750,
|
|
333
|
+
retryCount: safeScene.retryCount ?? 3,
|
|
334
|
+
retryDelayMs: safeScene.retryDelayMs ?? 750,
|
|
335
|
+
autoResetMs: safeScene.autoResetMs ?? 1000,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getActiveHousehold() {
|
|
340
|
+
if (!state.topology) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
return state.topology.households.find((household) => household.id === state.draft.householdId) || state.topology.households[0] || null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getPlayerName(playerId) {
|
|
347
|
+
const household = getActiveHousehold();
|
|
348
|
+
return household?.players.find((player) => player.id === playerId)?.name || playerId;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function serializeDraft() {
|
|
352
|
+
const draft = clone(state.draft);
|
|
353
|
+
draft.name = elements.sceneName.value.trim() || "New Scene";
|
|
354
|
+
draft.householdId = elements.householdSelect.value;
|
|
355
|
+
draft.coordinatorPlayerId = elements.coordinatorSelect.value;
|
|
356
|
+
draft.memberPlayerIds = Array.from(elements.memberList.querySelectorAll("input[type='checkbox']:checked")).map((checkbox) => checkbox.value);
|
|
357
|
+
draft.source = buildSourcePayload();
|
|
358
|
+
draft.coordinatorVolume = elements.coordinatorVolume.value === "" ? undefined : Number(elements.coordinatorVolume.value);
|
|
359
|
+
draft.settleMs = Number(elements.settleMs.value || 0);
|
|
360
|
+
draft.retryCount = Number(elements.retryCount.value || 0);
|
|
361
|
+
draft.retryDelayMs = Number(elements.retryDelayMs.value || 0);
|
|
362
|
+
draft.autoResetMs = Number(elements.autoResetMs.value || 0);
|
|
363
|
+
draft.offBehavior = { kind: elements.offBehavior.value };
|
|
364
|
+
draft.playerVolumes = Array.from(elements.volumeList.querySelectorAll("input[data-player-id]"))
|
|
365
|
+
.filter((input) => input.value !== "")
|
|
366
|
+
.map((input) => ({
|
|
367
|
+
playerId: input.dataset.playerId,
|
|
368
|
+
volume: Number(input.value),
|
|
369
|
+
}));
|
|
370
|
+
state.draft = normalizeScene(draft);
|
|
371
|
+
return clone(state.draft);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function buildSourcePayload() {
|
|
375
|
+
const kind = elements.sourceKind.value;
|
|
376
|
+
if (kind === "favorite") {
|
|
377
|
+
return {
|
|
378
|
+
kind,
|
|
379
|
+
favoriteId: elements.sourceTarget.value,
|
|
380
|
+
favoriteName: elements.sourceTarget.selectedOptions[0]?.textContent || "",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
kind,
|
|
386
|
+
deviceId: elements.sourceTarget.value,
|
|
387
|
+
deviceName: elements.sourceTarget.selectedOptions[0]?.textContent || "",
|
|
388
|
+
playOnCompletion: true,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ensureDraft() {
|
|
393
|
+
if (state.draft) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const scene = state.config.scenes[0] || normalizeScene({});
|
|
397
|
+
state.activeSceneId = scene.id;
|
|
398
|
+
state.draft = normalizeScene(scene);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderSceneList() {
|
|
402
|
+
elements.sceneList.innerHTML = "";
|
|
403
|
+
if (state.config.scenes.length === 0) {
|
|
404
|
+
const empty = document.createElement("div");
|
|
405
|
+
empty.className = "scene-help";
|
|
406
|
+
empty.textContent = "No scenes saved yet. Create one from the editor.";
|
|
407
|
+
elements.sceneList.appendChild(empty);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
state.config.scenes.forEach((scene) => {
|
|
412
|
+
const button = document.createElement("button");
|
|
413
|
+
button.type = "button";
|
|
414
|
+
button.className = scene.id === state.activeSceneId ? "active" : "";
|
|
415
|
+
button.innerHTML = `<strong>${scene.name}</strong><div class="scene-help">${scene.householdId || "No household selected"}</div>`;
|
|
416
|
+
button.addEventListener("click", () => {
|
|
417
|
+
state.activeSceneId = scene.id;
|
|
418
|
+
state.draft = normalizeScene(scene);
|
|
419
|
+
state.validation = null;
|
|
420
|
+
state.lastRun = null;
|
|
421
|
+
render();
|
|
422
|
+
});
|
|
423
|
+
elements.sceneList.appendChild(button);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function renderHouseholdOptions() {
|
|
428
|
+
const households = state.topology?.households || [];
|
|
429
|
+
const currentId = state.draft.householdId || state.config.defaultHouseholdId || households[0]?.id || "";
|
|
430
|
+
state.draft.householdId = currentId;
|
|
431
|
+
elements.householdSelect.innerHTML = households
|
|
432
|
+
.map((household) => `<option value="${household.id}" ${household.id === currentId ? "selected" : ""}>${household.displayName}</option>`)
|
|
433
|
+
.join("");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderCoordinatorOptions() {
|
|
437
|
+
const household = getActiveHousehold();
|
|
438
|
+
const players = household?.players || [];
|
|
439
|
+
const currentCoordinator = players.some((player) => player.id === state.draft.coordinatorPlayerId)
|
|
440
|
+
? state.draft.coordinatorPlayerId
|
|
441
|
+
: players[0]?.id || "";
|
|
442
|
+
state.draft.coordinatorPlayerId = currentCoordinator;
|
|
443
|
+
elements.coordinatorSelect.innerHTML = players
|
|
444
|
+
.map((player) => `<option value="${player.id}" ${player.id === currentCoordinator ? "selected" : ""}>${player.name}</option>`)
|
|
445
|
+
.join("");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function renderMemberOptions() {
|
|
449
|
+
const household = getActiveHousehold();
|
|
450
|
+
const coordinatorId = state.draft.coordinatorPlayerId;
|
|
451
|
+
const selected = new Set(state.draft.memberPlayerIds.filter((playerId) => playerId !== coordinatorId));
|
|
452
|
+
const players = (household?.players || []).filter((player) => player.id !== coordinatorId);
|
|
453
|
+
elements.memberList.innerHTML = players.length === 0
|
|
454
|
+
? `<div class="scene-help">No additional players are available for grouping.</div>`
|
|
455
|
+
: players
|
|
456
|
+
.map(
|
|
457
|
+
(player) => `
|
|
458
|
+
<label class="scene-member-pill">
|
|
459
|
+
<input class="form-check-input me-2" type="checkbox" value="${player.id}" ${selected.has(player.id) ? "checked" : ""}>
|
|
460
|
+
<strong>${player.name}</strong><br>
|
|
461
|
+
<span class="scene-help">${(player.sourceOptions || []).join(", ") || "favorite"}</span>
|
|
462
|
+
</label>
|
|
463
|
+
`,
|
|
464
|
+
)
|
|
465
|
+
.join("");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function renderSourceControls() {
|
|
469
|
+
const household = getActiveHousehold();
|
|
470
|
+
const coordinator = household?.players.find((player) => player.id === state.draft.coordinatorPlayerId);
|
|
471
|
+
const supportedKinds = ["favorite", ...new Set((coordinator?.sourceOptions || []).filter((kind) => kind !== "favorite"))];
|
|
472
|
+
const sourceKind = supportedKinds.includes(state.draft.source?.kind) ? state.draft.source.kind : supportedKinds[0] || "favorite";
|
|
473
|
+
state.draft.source = state.draft.source || { kind: sourceKind, favoriteId: "" };
|
|
474
|
+
state.draft.source.kind = sourceKind;
|
|
475
|
+
|
|
476
|
+
elements.sourceKind.innerHTML = supportedKinds
|
|
477
|
+
.map((kind) => `<option value="${kind}" ${kind === sourceKind ? "selected" : ""}>${kind.replace("_", " ")}</option>`)
|
|
478
|
+
.join("");
|
|
479
|
+
|
|
480
|
+
if (sourceKind === "favorite") {
|
|
481
|
+
const favorites = household?.favorites || [];
|
|
482
|
+
const selectedFavorite = favorites.some((favorite) => favorite.id === state.draft.source.favoriteId)
|
|
483
|
+
? state.draft.source.favoriteId
|
|
484
|
+
: favorites[0]?.id || "";
|
|
485
|
+
state.draft.source = {
|
|
486
|
+
kind: "favorite",
|
|
487
|
+
favoriteId: selectedFavorite,
|
|
488
|
+
favoriteName: favorites.find((favorite) => favorite.id === selectedFavorite)?.name || "",
|
|
489
|
+
};
|
|
490
|
+
elements.sourceTarget.innerHTML = favorites
|
|
491
|
+
.map((favorite) => `<option value="${favorite.id}" ${favorite.id === selectedFavorite ? "selected" : ""}>${favorite.name}</option>`)
|
|
492
|
+
.join("");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const sourcePlayers = (household?.players || []).filter((player) => (player.sourceOptions || []).includes(sourceKind));
|
|
497
|
+
const selectedPlayer = sourcePlayers.some((player) => player.id === state.draft.source.deviceId)
|
|
498
|
+
? state.draft.source.deviceId
|
|
499
|
+
: sourcePlayers[0]?.id || "";
|
|
500
|
+
state.draft.source = {
|
|
501
|
+
kind: sourceKind,
|
|
502
|
+
deviceId: selectedPlayer,
|
|
503
|
+
deviceName: sourcePlayers.find((player) => player.id === selectedPlayer)?.name || "",
|
|
504
|
+
playOnCompletion: true,
|
|
505
|
+
};
|
|
506
|
+
elements.sourceTarget.innerHTML = sourcePlayers
|
|
507
|
+
.map((player) => `<option value="${player.id}" ${player.id === selectedPlayer ? "selected" : ""}>${player.name}</option>`)
|
|
508
|
+
.join("");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderVolumeControls() {
|
|
512
|
+
const household = getActiveHousehold();
|
|
513
|
+
const selectedMembers = state.draft.memberPlayerIds || [];
|
|
514
|
+
const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
|
|
515
|
+
elements.volumeList.innerHTML = selectedMembers.length === 0
|
|
516
|
+
? `<div class="scene-help">Select one or more member rooms to configure per-room volume overrides.</div>`
|
|
517
|
+
: selectedMembers
|
|
518
|
+
.map((playerId) => `
|
|
519
|
+
<label class="scene-member-pill">
|
|
520
|
+
<span class="d-block mb-1"><strong>${getPlayerName(playerId)}</strong></span>
|
|
521
|
+
<input class="form-control" data-player-id="${playerId}" type="number" min="0" max="100" value="${values.get(playerId) ?? ""}">
|
|
522
|
+
</label>
|
|
523
|
+
`)
|
|
524
|
+
.join("");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renderValidation() {
|
|
528
|
+
if (!state.validation && !state.lastRun) {
|
|
529
|
+
elements.validationOutput.innerHTML = `<div class="scene-help">Validation messages and test results will appear here.</div>`;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const sections = [];
|
|
534
|
+
if (state.validation) {
|
|
535
|
+
sections.push(`
|
|
536
|
+
<div class="mb-3">
|
|
537
|
+
<span class="badge ${state.validation.validation.valid ? "bg-success" : "bg-danger"}">
|
|
538
|
+
${state.validation.validation.valid ? "Valid" : "Needs Attention"}
|
|
539
|
+
</span>
|
|
540
|
+
</div>
|
|
541
|
+
${renderMessageList("Errors", state.validation.validation.errors, "danger")}
|
|
542
|
+
${renderMessageList("Warnings", state.validation.validation.warnings, "warning")}
|
|
543
|
+
`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (state.lastRun) {
|
|
547
|
+
sections.push(`
|
|
548
|
+
<div class="mb-2">
|
|
549
|
+
<span class="badge ${state.lastRun.ok ? "bg-success" : "bg-danger"}">
|
|
550
|
+
${state.lastRun.ok ? "Test Passed" : "Test Failed"}
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
${renderMessageList("Run Errors", state.lastRun.errors || [], "danger")}
|
|
554
|
+
`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
elements.validationOutput.innerHTML = sections.join("");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function renderMessageList(title, messages, tone) {
|
|
561
|
+
if (!messages || messages.length === 0) {
|
|
562
|
+
return "";
|
|
563
|
+
}
|
|
564
|
+
return `
|
|
565
|
+
<div class="alert alert-${tone} py-2">
|
|
566
|
+
<strong>${title}</strong>
|
|
567
|
+
<ul class="mb-0 mt-2">
|
|
568
|
+
${messages.map((message) => `<li>${message}</li>`).join("")}
|
|
569
|
+
</ul>
|
|
570
|
+
</div>
|
|
571
|
+
`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function renderLogs() {
|
|
575
|
+
const logLines = (state.lastRun?.logs || []).map((entry) => {
|
|
576
|
+
return `<div class="scene-log-line">[${entry.level.toUpperCase()}] ${entry.scope}: ${entry.message}</div>`;
|
|
577
|
+
});
|
|
578
|
+
elements.logOutput.innerHTML = logLines.length > 0
|
|
579
|
+
? logLines.join("")
|
|
580
|
+
: `<div class="scene-log-line">No run logs yet.</div>`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderDiscovery() {
|
|
584
|
+
const households = state.topology?.households || [];
|
|
585
|
+
if (households.length === 0) {
|
|
586
|
+
elements.discoverySummary.textContent = "No Sonos households discovered yet.";
|
|
587
|
+
elements.discoveryGrid.innerHTML = "";
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
elements.discoverySummary.textContent = `Snapshot origin: ${state.topology.origin}. ${households.length} household(s) discovered at ${new Date(state.topology.capturedAt).toLocaleString()}.`;
|
|
592
|
+
elements.discoveryGrid.innerHTML = households
|
|
593
|
+
.map((household) => {
|
|
594
|
+
const groups = household.groups
|
|
595
|
+
.map((group) => {
|
|
596
|
+
const memberNames = group.playerIds.map((playerId) => household.players.find((player) => player.id === playerId)?.name || playerId);
|
|
597
|
+
return `<li><strong>${group.name}</strong>: ${memberNames.join(", ")}</li>`;
|
|
598
|
+
})
|
|
599
|
+
.join("");
|
|
600
|
+
|
|
601
|
+
const players = household.players
|
|
602
|
+
.map((player) => `<li><strong>${player.name}</strong> <span class="scene-help">${player.model || "Unknown model"} · ${(player.sourceOptions || []).join(", ")}</span></li>`)
|
|
603
|
+
.join("");
|
|
604
|
+
|
|
605
|
+
const favorites = (household.favorites || []).map((favorite) => `<li>${favorite.name}</li>`).join("");
|
|
606
|
+
|
|
607
|
+
return `
|
|
608
|
+
<div class="card h-100">
|
|
609
|
+
<div class="card-body">
|
|
610
|
+
<h5 class="card-title">${household.displayName}</h5>
|
|
611
|
+
<p class="scene-help mb-3">${household.id}</p>
|
|
612
|
+
<strong>Players</strong>
|
|
613
|
+
<ul>${players || "<li>None</li>"}</ul>
|
|
614
|
+
<strong>Current Groups</strong>
|
|
615
|
+
<ul>${groups || "<li>None</li>"}</ul>
|
|
616
|
+
<strong>Favorites</strong>
|
|
617
|
+
<ul>${favorites || "<li>None</li>"}</ul>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
`;
|
|
621
|
+
})
|
|
622
|
+
.join("");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function renderInputs() {
|
|
626
|
+
elements.sceneName.value = state.draft.name || "";
|
|
627
|
+
elements.sceneId.value = state.draft.id || "";
|
|
628
|
+
renderHouseholdOptions();
|
|
629
|
+
renderCoordinatorOptions();
|
|
630
|
+
renderMemberOptions();
|
|
631
|
+
renderSourceControls();
|
|
632
|
+
elements.coordinatorVolume.value = state.draft.coordinatorVolume === "" ? "" : state.draft.coordinatorVolume ?? "";
|
|
633
|
+
elements.settleMs.value = state.draft.settleMs ?? 750;
|
|
634
|
+
elements.retryCount.value = state.draft.retryCount ?? 3;
|
|
635
|
+
elements.retryDelayMs.value = state.draft.retryDelayMs ?? 750;
|
|
636
|
+
elements.autoResetMs.value = state.draft.autoResetMs ?? 1000;
|
|
637
|
+
elements.offBehavior.value = state.draft.offBehavior?.kind || "none";
|
|
638
|
+
renderVolumeControls();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function render() {
|
|
642
|
+
ensureDraft();
|
|
643
|
+
renderSceneList();
|
|
644
|
+
renderInputs();
|
|
645
|
+
renderValidation();
|
|
646
|
+
renderLogs();
|
|
647
|
+
renderDiscovery();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function persistConfig(showToast) {
|
|
651
|
+
await homebridge.updatePluginConfig([clone(state.config)]);
|
|
652
|
+
if (showToast) {
|
|
653
|
+
homebridge.toast.success("Scene configuration updated in Homebridge memory.");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function discover() {
|
|
658
|
+
homebridge.showSpinner();
|
|
659
|
+
try {
|
|
660
|
+
const result = await homebridge.request("/discover", { config: state.config });
|
|
661
|
+
state.topology = result.snapshot;
|
|
662
|
+
if (!state.draft.householdId && state.topology.households[0]) {
|
|
663
|
+
state.draft.householdId = state.config.defaultHouseholdId || state.topology.households[0].id;
|
|
664
|
+
}
|
|
665
|
+
render();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
homebridge.toast.error(error.message || "Discovery failed.");
|
|
668
|
+
} finally {
|
|
669
|
+
homebridge.hideSpinner();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function validateDraft() {
|
|
674
|
+
serializeDraft();
|
|
675
|
+
homebridge.showSpinner();
|
|
676
|
+
try {
|
|
677
|
+
state.validation = await homebridge.request("/validate-scene", {
|
|
678
|
+
config: state.config,
|
|
679
|
+
scene: state.draft,
|
|
680
|
+
});
|
|
681
|
+
render();
|
|
682
|
+
} catch (error) {
|
|
683
|
+
homebridge.toast.error(error.message || "Validation failed.");
|
|
684
|
+
} finally {
|
|
685
|
+
homebridge.hideSpinner();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function runTest() {
|
|
690
|
+
serializeDraft();
|
|
691
|
+
homebridge.showSpinner();
|
|
692
|
+
try {
|
|
693
|
+
state.lastRun = await homebridge.request("/run-test", {
|
|
694
|
+
config: state.config,
|
|
695
|
+
scene: state.draft,
|
|
696
|
+
});
|
|
697
|
+
render();
|
|
698
|
+
} catch (error) {
|
|
699
|
+
homebridge.toast.error(error.message || "Scene test failed.");
|
|
700
|
+
} finally {
|
|
701
|
+
homebridge.hideSpinner();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function saveScene() {
|
|
706
|
+
const draft = serializeDraft();
|
|
707
|
+
const index = state.config.scenes.findIndex((scene) => scene.id === draft.id);
|
|
708
|
+
if (index >= 0) {
|
|
709
|
+
state.config.scenes[index] = draft;
|
|
710
|
+
} else {
|
|
711
|
+
state.config.scenes.push(draft);
|
|
712
|
+
}
|
|
713
|
+
state.activeSceneId = draft.id;
|
|
714
|
+
await persistConfig(true);
|
|
715
|
+
render();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function deleteScene() {
|
|
719
|
+
if (!state.draft) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
state.config.scenes = state.config.scenes.filter((scene) => scene.id !== state.draft.id);
|
|
723
|
+
state.activeSceneId = state.config.scenes[0]?.id || null;
|
|
724
|
+
state.draft = state.config.scenes[0] ? normalizeScene(state.config.scenes[0]) : normalizeScene({});
|
|
725
|
+
await persistConfig(true);
|
|
726
|
+
render();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function newScene() {
|
|
730
|
+
state.activeSceneId = null;
|
|
731
|
+
state.draft = normalizeScene({
|
|
732
|
+
id: randomId(),
|
|
733
|
+
householdId: state.topology?.households[0]?.id || state.config.defaultHouseholdId || "",
|
|
734
|
+
});
|
|
735
|
+
state.validation = null;
|
|
736
|
+
state.lastRun = null;
|
|
737
|
+
render();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function saveToHomebridge() {
|
|
741
|
+
await persistConfig(false);
|
|
742
|
+
await homebridge.savePluginConfig();
|
|
743
|
+
homebridge.toast.success("Configuration saved to config.json.");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function bindEvents() {
|
|
747
|
+
elements.refreshButton.addEventListener("click", discover);
|
|
748
|
+
elements.validateButton.addEventListener("click", validateDraft);
|
|
749
|
+
elements.testButton.addEventListener("click", runTest);
|
|
750
|
+
elements.saveSceneButton.addEventListener("click", saveScene);
|
|
751
|
+
elements.deleteSceneButton.addEventListener("click", deleteScene);
|
|
752
|
+
elements.newSceneButton.addEventListener("click", newScene);
|
|
753
|
+
elements.saveHomebridgeButton.addEventListener("click", saveToHomebridge);
|
|
754
|
+
|
|
755
|
+
elements.householdSelect.addEventListener("change", () => {
|
|
756
|
+
serializeDraft();
|
|
757
|
+
state.draft.coordinatorPlayerId = "";
|
|
758
|
+
state.draft.memberPlayerIds = [];
|
|
759
|
+
state.draft.playerVolumes = [];
|
|
760
|
+
render();
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
elements.coordinatorSelect.addEventListener("change", () => {
|
|
764
|
+
serializeDraft();
|
|
765
|
+
state.draft.memberPlayerIds = state.draft.memberPlayerIds.filter((playerId) => playerId !== state.draft.coordinatorPlayerId);
|
|
766
|
+
render();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
elements.sourceKind.addEventListener("change", () => {
|
|
770
|
+
serializeDraft();
|
|
771
|
+
state.draft.source = elements.sourceKind.value === "favorite"
|
|
772
|
+
? { kind: "favorite", favoriteId: "" }
|
|
773
|
+
: { kind: elements.sourceKind.value, deviceId: "", playOnCompletion: true };
|
|
774
|
+
render();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
homebridge.addEventListener("scene-test-result", (event) => {
|
|
778
|
+
state.lastRun = event.data;
|
|
779
|
+
render();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
homebridge.addEventListener("configChanged", (event) => {
|
|
783
|
+
const schemaConfig = normalizeConfig(event.data);
|
|
784
|
+
state.config = Object.assign({}, state.config, schemaConfig, {
|
|
785
|
+
scenes: state.config.scenes,
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function init() {
|
|
791
|
+
homebridge.showSchemaForm();
|
|
792
|
+
bindEvents();
|
|
793
|
+
|
|
794
|
+
const pluginConfig = await homebridge.getPluginConfig();
|
|
795
|
+
if (!pluginConfig.length) {
|
|
796
|
+
state.config = normalizeConfig(await homebridge.request("/defaults"));
|
|
797
|
+
await homebridge.updatePluginConfig([clone(state.config)]);
|
|
798
|
+
} else {
|
|
799
|
+
state.config = normalizeConfig(pluginConfig[0]);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
state.activeSceneId = state.config.scenes[0]?.id || null;
|
|
803
|
+
state.draft = state.config.scenes[0] ? normalizeScene(state.config.scenes[0]) : normalizeScene({});
|
|
804
|
+
render();
|
|
805
|
+
await discover();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
homebridge.addEventListener("ready", init);
|
|
809
|
+
})();
|
|
810
|
+
</script>
|