homebridge-sonos-scenes 0.1.5 → 0.1.7
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/README.md +10 -1
- package/docs/assets/icon-1024.png +0 -0
- package/docs/assets/icon-256.png +0 -0
- package/docs/assets/icon-512.png +0 -0
- package/homebridge-ui/public/index.html +122 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/icon-512.png" alt="homebridge-sonos-scenes icon" width="164">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">homebridge-sonos-scenes</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">Homebridge plugin for Sonos workflow scenes and orchestration.</p>
|
|
2
8
|
|
|
3
9
|
`homebridge-sonos-scenes` is a Homebridge plugin scaffold for Sonos workflow scenes.
|
|
4
10
|
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> This project is in an active early-testing phase. The local-first scene workflow is usable and published to npm, but cloud-backed Sonos playback is still planned work and some edge cases are still being hardened. If you try the plugin, please share bugs, UI feedback, and Sonos compatibility notes in [GitHub Issues](https://github.com/applemanj/homebridge-sonos-scenes/issues). Real-world feedback is especially helpful right now.
|
|
13
|
+
|
|
5
14
|
The goal is not general Sonos control. The goal is a clean way to trigger multi-step Sonos workflows from Apple Home, such as:
|
|
6
15
|
|
|
7
16
|
- grouping rooms around a coordinator,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
border: 1px solid rgba(22, 32, 51, 0.08);
|
|
38
38
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
|
39
39
|
background: white;
|
|
40
|
-
overflow:
|
|
40
|
+
overflow: visible;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
.scene-mode-panel .card-header {
|
|
@@ -45,6 +45,34 @@
|
|
|
45
45
|
background: rgba(255, 255, 255, 0.65);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
.scene-section-header {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
align-items: flex-start;
|
|
52
|
+
justify-content: space-between;
|
|
53
|
+
gap: 0.85rem 1rem;
|
|
54
|
+
padding: 1rem 1.15rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.scene-section-header-copy {
|
|
58
|
+
display: grid;
|
|
59
|
+
gap: 0.35rem;
|
|
60
|
+
min-width: 0;
|
|
61
|
+
flex: 1 1 260px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.scene-section-actions {
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-wrap: wrap;
|
|
67
|
+
gap: 0.65rem;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: flex-end;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.scene-section-actions .btn {
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
.scene-mode-panel .card-body {
|
|
49
77
|
display: grid;
|
|
50
78
|
gap: 1rem;
|
|
@@ -66,6 +94,8 @@
|
|
|
66
94
|
|
|
67
95
|
.scene-mode-choice input {
|
|
68
96
|
margin-right: 0.65rem;
|
|
97
|
+
accent-color: var(--scene-accent);
|
|
98
|
+
transform: scale(1.08);
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
.scene-mode-choice-title {
|
|
@@ -148,7 +178,7 @@
|
|
|
148
178
|
|
|
149
179
|
.scene-card .scene-help,
|
|
150
180
|
.scene-mode-panel .scene-help {
|
|
151
|
-
color: #
|
|
181
|
+
color: #556579;
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
.scene-list button {
|
|
@@ -277,12 +307,22 @@
|
|
|
277
307
|
display: block;
|
|
278
308
|
}
|
|
279
309
|
|
|
310
|
+
.scene-member-pill.selected {
|
|
311
|
+
border-color: rgba(10, 132, 255, 0.34);
|
|
312
|
+
background: rgba(10, 132, 255, 0.07);
|
|
313
|
+
box-shadow: inset 0 0 0 1px rgba(10, 132, 255, 0.08);
|
|
314
|
+
}
|
|
315
|
+
|
|
280
316
|
.scene-member-row {
|
|
281
317
|
display: flex;
|
|
282
318
|
align-items: flex-start;
|
|
283
319
|
gap: 0.65rem;
|
|
284
320
|
}
|
|
285
321
|
|
|
322
|
+
.scene-member-toggle {
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
}
|
|
325
|
+
|
|
286
326
|
.scene-member-pill .form-check-input {
|
|
287
327
|
margin-top: 0.2rem;
|
|
288
328
|
flex: 0 0 auto;
|
|
@@ -306,6 +346,25 @@
|
|
|
306
346
|
line-height: 1.35;
|
|
307
347
|
}
|
|
308
348
|
|
|
349
|
+
.scene-member-volume {
|
|
350
|
+
display: grid;
|
|
351
|
+
gap: 0.35rem;
|
|
352
|
+
margin-top: 0.65rem;
|
|
353
|
+
padding-top: 0.65rem;
|
|
354
|
+
border-top: 1px solid rgba(22, 32, 51, 0.08);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.scene-member-volume-label {
|
|
358
|
+
color: #556579;
|
|
359
|
+
font-size: 0.78rem;
|
|
360
|
+
font-weight: 600;
|
|
361
|
+
line-height: 1.3;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.scene-member-volume-input {
|
|
365
|
+
max-width: 132px;
|
|
366
|
+
}
|
|
367
|
+
|
|
309
368
|
.scene-log {
|
|
310
369
|
max-height: 280px;
|
|
311
370
|
overflow: auto;
|
|
@@ -334,7 +393,7 @@
|
|
|
334
393
|
}
|
|
335
394
|
|
|
336
395
|
.scene-inline-note {
|
|
337
|
-
color: #
|
|
396
|
+
color: #5f6f84;
|
|
338
397
|
font-size: 0.88rem;
|
|
339
398
|
line-height: 1.45;
|
|
340
399
|
}
|
|
@@ -344,10 +403,10 @@
|
|
|
344
403
|
align-items: center;
|
|
345
404
|
gap: 0.4rem;
|
|
346
405
|
flex-wrap: wrap;
|
|
406
|
+
position: relative;
|
|
347
407
|
}
|
|
348
408
|
|
|
349
409
|
.scene-info-tag {
|
|
350
|
-
position: relative;
|
|
351
410
|
display: inline-flex;
|
|
352
411
|
align-items: center;
|
|
353
412
|
justify-content: center;
|
|
@@ -370,10 +429,11 @@
|
|
|
370
429
|
|
|
371
430
|
.scene-tooltip {
|
|
372
431
|
position: absolute;
|
|
373
|
-
top:
|
|
374
|
-
left:
|
|
375
|
-
transform:
|
|
376
|
-
width: min(320px,
|
|
432
|
+
top: calc(100% + 0.45rem);
|
|
433
|
+
left: 0;
|
|
434
|
+
transform: none;
|
|
435
|
+
width: min(320px, calc(100vw - 4rem));
|
|
436
|
+
max-width: 100%;
|
|
377
437
|
padding: 0.65rem 0.75rem;
|
|
378
438
|
border-radius: 0.7rem;
|
|
379
439
|
border: 1px solid rgba(170, 198, 255, 0.28);
|
|
@@ -400,15 +460,6 @@
|
|
|
400
460
|
grid-template-columns: 1fr;
|
|
401
461
|
}
|
|
402
462
|
}
|
|
403
|
-
|
|
404
|
-
@media (max-width: 720px) {
|
|
405
|
-
.scene-tooltip {
|
|
406
|
-
top: calc(100% + 0.45rem);
|
|
407
|
-
left: 0;
|
|
408
|
-
transform: none;
|
|
409
|
-
width: min(280px, 78vw);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
463
|
</style>
|
|
413
464
|
|
|
414
465
|
<div class="scene-shell">
|
|
@@ -444,12 +495,11 @@
|
|
|
444
495
|
|
|
445
496
|
<section class="scene-mode-grid">
|
|
446
497
|
<section class="scene-mode-panel card scene-card">
|
|
447
|
-
<div class="card-header
|
|
448
|
-
<div>
|
|
498
|
+
<div class="card-header scene-section-header">
|
|
499
|
+
<div class="scene-section-header-copy">
|
|
449
500
|
<strong>Execution Mode</strong>
|
|
450
501
|
<div class="scene-help">Choose whether scenes stay fully local or can use a self-hosted Sonos cloud broker later.</div>
|
|
451
502
|
</div>
|
|
452
|
-
<div id="cloud-mode-pill" class="scene-status-pill local">Local Only</div>
|
|
453
503
|
</div>
|
|
454
504
|
<div class="card-body d-grid gap-3">
|
|
455
505
|
<label class="scene-mode-choice" data-mode-choice="local_only">
|
|
@@ -539,13 +589,15 @@
|
|
|
539
589
|
|
|
540
590
|
<div class="scene-grid">
|
|
541
591
|
<section class="card scene-card">
|
|
542
|
-
<div class="card-header
|
|
543
|
-
<div>
|
|
592
|
+
<div class="card-header scene-section-header">
|
|
593
|
+
<div class="scene-section-header-copy">
|
|
544
594
|
<strong>Scenes</strong>
|
|
545
595
|
<div class="scene-help">Each saved scene becomes a HomeKit switch plus a companion volume speaker.</div>
|
|
546
596
|
<div class="scene-inline-note mt-2">Save scene changes in the editor, then use Homebridge's footer Save button when you're ready to write everything to `config.json`.</div>
|
|
547
597
|
</div>
|
|
548
|
-
<
|
|
598
|
+
<div class="scene-section-actions">
|
|
599
|
+
<button class="btn btn-sm btn-outline-primary" id="new-scene-button" type="button">New Scene</button>
|
|
600
|
+
</div>
|
|
549
601
|
</div>
|
|
550
602
|
<div class="card-body">
|
|
551
603
|
<div id="scene-list" class="scene-list"></div>
|
|
@@ -553,13 +605,13 @@
|
|
|
553
605
|
</section>
|
|
554
606
|
|
|
555
607
|
<section class="card scene-card">
|
|
556
|
-
<div class="card-header
|
|
557
|
-
<div>
|
|
608
|
+
<div class="card-header scene-section-header">
|
|
609
|
+
<div class="scene-section-header-copy">
|
|
558
610
|
<strong>Scene Editor</strong>
|
|
559
611
|
<div class="scene-help">Stable Sonos IDs are stored under the hood; the UI keeps room selection friendly.</div>
|
|
560
612
|
<div class="scene-inline-note mt-2">Save Scene Changes updates this scene in the list above. Homebridge's footer Save button writes the full plugin config to disk.</div>
|
|
561
613
|
</div>
|
|
562
|
-
<div class="
|
|
614
|
+
<div class="scene-section-actions">
|
|
563
615
|
<button class="btn btn-sm btn-outline-danger" id="delete-scene-button" type="button">Delete</button>
|
|
564
616
|
<button class="btn btn-sm btn-primary" id="save-scene-button" type="button">Save Scene Changes</button>
|
|
565
617
|
</div>
|
|
@@ -609,7 +661,7 @@
|
|
|
609
661
|
<span>Group Members</span>
|
|
610
662
|
<span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Group Members">
|
|
611
663
|
?
|
|
612
|
-
<span class="scene-tooltip">Additional rooms to join the coordinator when the scene runs. Leave all unchecked for a single-room scene.</span>
|
|
664
|
+
<span class="scene-tooltip">Additional rooms to join the coordinator when the scene runs. Leave all unchecked for a single-room scene. Selected member tiles can also carry their own optional volume override.</span>
|
|
613
665
|
</span>
|
|
614
666
|
</span>
|
|
615
667
|
</label>
|
|
@@ -714,18 +766,6 @@
|
|
|
714
766
|
<option value="ungroup">Ungroup</option>
|
|
715
767
|
</select>
|
|
716
768
|
</div>
|
|
717
|
-
<div class="col-12">
|
|
718
|
-
<label class="form-label">
|
|
719
|
-
<span class="scene-label-row">
|
|
720
|
-
<span>Per-room Volumes</span>
|
|
721
|
-
<span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Per-room Volumes">
|
|
722
|
-
?
|
|
723
|
-
<span class="scene-tooltip">Optional volume overrides for specific member rooms. Leave a room blank here to keep its current volume or let the group volume behavior apply.</span>
|
|
724
|
-
</span>
|
|
725
|
-
</span>
|
|
726
|
-
</label>
|
|
727
|
-
<div id="volume-list" class="scene-member-grid"></div>
|
|
728
|
-
</div>
|
|
729
769
|
</div>
|
|
730
770
|
</div>
|
|
731
771
|
</section>
|
|
@@ -781,7 +821,6 @@
|
|
|
781
821
|
retryDelayMs: document.getElementById("retry-delay-ms"),
|
|
782
822
|
autoResetMs: document.getElementById("auto-reset-ms"),
|
|
783
823
|
offBehavior: document.getElementById("off-behavior"),
|
|
784
|
-
volumeList: document.getElementById("volume-list"),
|
|
785
824
|
validationOutput: document.getElementById("validation-output"),
|
|
786
825
|
logOutput: document.getElementById("log-output"),
|
|
787
826
|
discoverySummary: document.getElementById("discovery-summary"),
|
|
@@ -791,7 +830,6 @@
|
|
|
791
830
|
testButton: document.getElementById("test-button"),
|
|
792
831
|
cloudModeInputs: Array.from(document.querySelectorAll("input[name='cloud-mode']")),
|
|
793
832
|
cloudModeChoices: Array.from(document.querySelectorAll("[data-mode-choice]")),
|
|
794
|
-
cloudModePill: document.getElementById("cloud-mode-pill"),
|
|
795
833
|
cloudBrokerPanel: document.getElementById("cloud-broker-panel"),
|
|
796
834
|
cloudBrokerCopy: document.getElementById("cloud-broker-copy"),
|
|
797
835
|
cloudBrokerFields: document.getElementById("cloud-broker-fields"),
|
|
@@ -934,7 +972,7 @@
|
|
|
934
972
|
draft.retryDelayMs = Number(elements.retryDelayMs.value || 0);
|
|
935
973
|
draft.autoResetMs = Number(elements.autoResetMs.value || 0);
|
|
936
974
|
draft.offBehavior = { kind: elements.offBehavior.value };
|
|
937
|
-
draft.playerVolumes = Array.from(elements.
|
|
975
|
+
draft.playerVolumes = Array.from(elements.memberList.querySelectorAll("input[data-player-id]"))
|
|
938
976
|
.filter((input) => input.value !== "")
|
|
939
977
|
.map((input) => ({
|
|
940
978
|
playerId: input.dataset.playerId,
|
|
@@ -1021,8 +1059,6 @@
|
|
|
1021
1059
|
});
|
|
1022
1060
|
|
|
1023
1061
|
const isHybrid = cloud.mode === "local_plus_cloud";
|
|
1024
|
-
elements.cloudModePill.textContent = isHybrid ? "Local + Cloud" : "Local Only";
|
|
1025
|
-
elements.cloudModePill.className = `scene-status-pill ${isHybrid ? "pending" : "local"}`;
|
|
1026
1062
|
elements.cloudBrokerPanel.classList.toggle("scene-cloud-hidden", !isHybrid);
|
|
1027
1063
|
elements.cloudBrokerCopy.textContent = isHybrid
|
|
1028
1064
|
? "Only needed if you run your own broker. The settings below stay with this plugin config and are ignored whenever Local Only is active."
|
|
@@ -1107,22 +1143,40 @@
|
|
|
1107
1143
|
const household = getActiveHousehold();
|
|
1108
1144
|
const coordinatorId = state.draft.coordinatorPlayerId;
|
|
1109
1145
|
const selected = new Set(state.draft.memberPlayerIds.filter((playerId) => playerId !== coordinatorId));
|
|
1146
|
+
const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
|
|
1110
1147
|
const players = (household?.players || []).filter((player) => player.id !== coordinatorId);
|
|
1111
1148
|
elements.memberList.innerHTML = players.length === 0
|
|
1112
1149
|
? `<div class="scene-help">No additional players are available for grouping.</div>`
|
|
1113
1150
|
: players
|
|
1114
1151
|
.map(
|
|
1115
|
-
(player) =>
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1152
|
+
(player) => {
|
|
1153
|
+
const isSelected = selected.has(player.id);
|
|
1154
|
+
return `
|
|
1155
|
+
<div class="scene-member-pill ${isSelected ? "selected" : ""}">
|
|
1156
|
+
<label class="scene-member-row scene-member-toggle">
|
|
1157
|
+
<input class="form-check-input" data-member-checkbox type="checkbox" value="${player.id}" ${isSelected ? "checked" : ""}>
|
|
1119
1158
|
<span class="scene-member-copy">
|
|
1120
1159
|
<span class="scene-member-title">${player.name}</span>
|
|
1121
1160
|
<span class="scene-member-meta">${player.model || "Unknown model"} - ${((player.sourceOptions || []).join(", ") || "favorite").replaceAll("_", " ")}</span>
|
|
1122
1161
|
</span>
|
|
1123
|
-
</
|
|
1124
|
-
|
|
1125
|
-
|
|
1162
|
+
</label>
|
|
1163
|
+
${isSelected ? `
|
|
1164
|
+
<div class="scene-member-volume">
|
|
1165
|
+
<label class="scene-member-volume-label" for="member-volume-${player.id}">Volume Override</label>
|
|
1166
|
+
<input
|
|
1167
|
+
class="form-control form-control-sm scene-member-volume-input"
|
|
1168
|
+
id="member-volume-${player.id}"
|
|
1169
|
+
data-player-id="${player.id}"
|
|
1170
|
+
type="number"
|
|
1171
|
+
min="0"
|
|
1172
|
+
max="100"
|
|
1173
|
+
placeholder="Keep current"
|
|
1174
|
+
value="${values.get(player.id) ?? ""}">
|
|
1175
|
+
</div>
|
|
1176
|
+
` : ""}
|
|
1177
|
+
</div>
|
|
1178
|
+
`;
|
|
1179
|
+
},
|
|
1126
1180
|
)
|
|
1127
1181
|
.join("");
|
|
1128
1182
|
}
|
|
@@ -1181,22 +1235,6 @@
|
|
|
1181
1235
|
.join("");
|
|
1182
1236
|
}
|
|
1183
1237
|
|
|
1184
|
-
function renderVolumeControls() {
|
|
1185
|
-
const household = getActiveHousehold();
|
|
1186
|
-
const selectedMembers = state.draft.memberPlayerIds || [];
|
|
1187
|
-
const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
|
|
1188
|
-
elements.volumeList.innerHTML = selectedMembers.length === 0
|
|
1189
|
-
? `<div class="scene-help">Select one or more member rooms to configure per-room volume overrides.</div>`
|
|
1190
|
-
: selectedMembers
|
|
1191
|
-
.map((playerId) => `
|
|
1192
|
-
<label class="scene-member-pill">
|
|
1193
|
-
<span class="scene-member-title d-block mb-1">${getPlayerName(playerId)}</span>
|
|
1194
|
-
<input class="form-control" data-player-id="${playerId}" type="number" min="0" max="100" value="${values.get(playerId) ?? ""}">
|
|
1195
|
-
</label>
|
|
1196
|
-
`)
|
|
1197
|
-
.join("");
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1238
|
function renderValidation() {
|
|
1201
1239
|
if (!state.validation && !state.lastRun) {
|
|
1202
1240
|
elements.validationOutput.innerHTML = `<div class="scene-help">Validation messages and test results will appear here.</div>`;
|
|
@@ -1322,7 +1360,6 @@
|
|
|
1322
1360
|
elements.retryDelayMs.value = state.draft.retryDelayMs ?? 750;
|
|
1323
1361
|
elements.autoResetMs.value = state.draft.autoResetMs ?? 1000;
|
|
1324
1362
|
elements.offBehavior.value = state.draft.offBehavior?.kind || "none";
|
|
1325
|
-
renderVolumeControls();
|
|
1326
1363
|
}
|
|
1327
1364
|
|
|
1328
1365
|
function render() {
|
|
@@ -1490,6 +1527,23 @@
|
|
|
1490
1527
|
});
|
|
1491
1528
|
});
|
|
1492
1529
|
|
|
1530
|
+
elements.memberList.addEventListener("change", (event) => {
|
|
1531
|
+
const target = event.target;
|
|
1532
|
+
if (!(target instanceof HTMLInputElement)) {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (target.matches("input[data-member-checkbox]")) {
|
|
1537
|
+
serializeDraft();
|
|
1538
|
+
renderMemberOptions();
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
if (target.matches("input[data-player-id]")) {
|
|
1543
|
+
serializeDraft();
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1493
1547
|
elements.householdSelect.addEventListener("change", () => {
|
|
1494
1548
|
serializeDraft();
|
|
1495
1549
|
state.draft.coordinatorPlayerId = "";
|
package/package.json
CHANGED