homebridge-sonos-scenes 0.1.3 → 0.1.4

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 CHANGED
@@ -41,6 +41,9 @@ examples/
41
41
  sample-topology.json
42
42
  docs/
43
43
  cloud-broker.md
44
+ broker/
45
+ README.md
46
+ src/server.mjs
44
47
  test/
45
48
  ```
46
49
 
@@ -97,6 +100,8 @@ This project does not host that broker for users. The goal is an optional self-h
97
100
 
98
101
  The config model already reserves a `cloud` section so advanced users and future versions do not need a breaking config redesign later. The broker contract is documented in [docs/cloud-broker.md](docs/cloud-broker.md).
99
102
 
103
+ There is also an early self-hosted broker scaffold in [broker/README.md](broker/README.md). It is not wired into scene execution yet, but it gives self-hosters a concrete service shape and a live `/v1/status` endpoint to target.
104
+
100
105
  ## Official Sonos References
101
106
 
102
107
  These docs informed the product boundaries and future cloud-adapter shape:
@@ -0,0 +1,5 @@
1
+ BROKER_PORT=8787
2
+ BROKER_HOST=127.0.0.1
3
+ BROKER_NAME=sonos-scenes-broker
4
+ BROKER_API_KEY=replace-me
5
+ BROKER_DOCS_URL=https://github.com/applemanj/homebridge-sonos-scenes/blob/main/docs/cloud-broker.md
@@ -0,0 +1,41 @@
1
+ # sonos-scenes-broker
2
+
3
+ This is the first self-hosted broker scaffold for `homebridge-sonos-scenes`.
4
+
5
+ It is intentionally small:
6
+
7
+ - it starts a local HTTP server
8
+ - it exposes `GET /healthz`
9
+ - it exposes `GET /v1/status`
10
+ - it reserves the future Sonos broker routes with `501 Not Implemented` responses
11
+
12
+ It does **not** implement Sonos OAuth or Sonos cloud playback yet. The point of this scaffold is to give self-hosters a concrete package and endpoint shape to build from.
13
+
14
+ ## Run It
15
+
16
+ ```bash
17
+ cd broker
18
+ node src/server.mjs
19
+ ```
20
+
21
+ With environment variables:
22
+
23
+ ```bash
24
+ BROKER_PORT=8787
25
+ BROKER_HOST=127.0.0.1
26
+ BROKER_API_KEY=replace-me
27
+ node src/server.mjs
28
+ ```
29
+
30
+ ## Current Endpoints
31
+
32
+ - `GET /healthz`
33
+ - `GET /v1/status`
34
+ - `GET /v1/households`
35
+ - `GET /v1/households/:householdId/groups`
36
+ - `GET /v1/households/:householdId/favorites`
37
+ - `GET /v1/households/:householdId/playlists`
38
+ - `POST /v1/groups/:groupId/favorites/load`
39
+ - `POST /v1/groups/:groupId/playlists/load`
40
+
41
+ The `status` endpoint is meant to be usable by the Homebridge plugin right away for configuration checks. The other endpoints are placeholders for the future Sonos OAuth and cloud-control work.
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "sonos-scenes-broker",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "description": "Self-hosted Sonos cloud broker scaffold for homebridge-sonos-scenes.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node src/server.mjs",
9
+ "dev": "node --watch src/server.mjs"
10
+ },
11
+ "engines": {
12
+ "node": ">=18.20.0"
13
+ },
14
+ "license": "MIT"
15
+ }
@@ -0,0 +1,151 @@
1
+ import { createServer } from "node:http";
2
+
3
+ const port = Number(process.env.BROKER_PORT || 8787);
4
+ const host = process.env.BROKER_HOST || "127.0.0.1";
5
+ const brokerName = process.env.BROKER_NAME || "sonos-scenes-broker";
6
+ const apiKey = (process.env.BROKER_API_KEY || "").trim();
7
+ const docsUrl = process.env.BROKER_DOCS_URL || "https://github.com/applemanj/homebridge-sonos-scenes/blob/main/docs/cloud-broker.md";
8
+
9
+ function writeJson(response, statusCode, payload) {
10
+ response.writeHead(statusCode, {
11
+ "content-type": "application/json; charset=utf-8",
12
+ "cache-control": "no-store",
13
+ });
14
+ response.end(JSON.stringify(payload, null, 2));
15
+ }
16
+
17
+ function bearerToken(request) {
18
+ const header = request.headers.authorization || "";
19
+ const match = /^Bearer\s+(.+)$/i.exec(header);
20
+ return match?.[1]?.trim() || "";
21
+ }
22
+
23
+ function isAuthorized(request) {
24
+ if (!apiKey) {
25
+ return true;
26
+ }
27
+
28
+ return bearerToken(request) === apiKey;
29
+ }
30
+
31
+ async function readJsonBody(request) {
32
+ const chunks = [];
33
+ for await (const chunk of request) {
34
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
35
+ }
36
+
37
+ if (chunks.length === 0) {
38
+ return {};
39
+ }
40
+
41
+ const raw = Buffer.concat(chunks).toString("utf8");
42
+ return raw.trim().length > 0 ? JSON.parse(raw) : {};
43
+ }
44
+
45
+ function notImplemented(response, route, extra = {}) {
46
+ writeJson(response, 501, {
47
+ ok: false,
48
+ route,
49
+ mode: "scaffold",
50
+ message: "This broker scaffold reserves the route, but Sonos OAuth and cloud playback are not implemented yet.",
51
+ ...extra,
52
+ });
53
+ }
54
+
55
+ const server = createServer(async (request, response) => {
56
+ const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
57
+ const pathname = url.pathname;
58
+ const method = request.method || "GET";
59
+
60
+ if (method === "GET" && pathname === "/healthz") {
61
+ writeJson(response, 200, {
62
+ ok: true,
63
+ name: brokerName,
64
+ mode: "scaffold",
65
+ });
66
+ return;
67
+ }
68
+
69
+ if (method === "GET" && pathname === "/v1/status") {
70
+ writeJson(response, 200, {
71
+ ok: true,
72
+ name: brokerName,
73
+ version: "0.0.1",
74
+ mode: "scaffold",
75
+ oauthConfigured: false,
76
+ features: ["favorites", "playlists"],
77
+ docsUrl,
78
+ message: "Broker scaffold is running. Sonos OAuth is not configured yet.",
79
+ });
80
+ return;
81
+ }
82
+
83
+ if (!isAuthorized(request)) {
84
+ writeJson(response, 401, {
85
+ ok: false,
86
+ message: "Unauthorized. Supply the configured broker bearer token.",
87
+ });
88
+ return;
89
+ }
90
+
91
+ try {
92
+ if (method === "GET" && pathname === "/v1/households") {
93
+ notImplemented(response, pathname, {
94
+ households: [],
95
+ });
96
+ return;
97
+ }
98
+
99
+ if (method === "GET" && /^\/v1\/households\/[^/]+\/groups$/.test(pathname)) {
100
+ notImplemented(response, pathname, {
101
+ groups: [],
102
+ players: [],
103
+ });
104
+ return;
105
+ }
106
+
107
+ if (method === "GET" && /^\/v1\/households\/[^/]+\/favorites$/.test(pathname)) {
108
+ notImplemented(response, pathname, {
109
+ favorites: [],
110
+ });
111
+ return;
112
+ }
113
+
114
+ if (method === "GET" && /^\/v1\/households\/[^/]+\/playlists$/.test(pathname)) {
115
+ notImplemented(response, pathname, {
116
+ playlists: [],
117
+ });
118
+ return;
119
+ }
120
+
121
+ if (method === "POST" && /^\/v1\/groups\/[^/]+\/favorites\/load$/.test(pathname)) {
122
+ const body = await readJsonBody(request);
123
+ notImplemented(response, pathname, {
124
+ received: body,
125
+ });
126
+ return;
127
+ }
128
+
129
+ if (method === "POST" && /^\/v1\/groups\/[^/]+\/playlists\/load$/.test(pathname)) {
130
+ const body = await readJsonBody(request);
131
+ notImplemented(response, pathname, {
132
+ received: body,
133
+ });
134
+ return;
135
+ }
136
+
137
+ writeJson(response, 404, {
138
+ ok: false,
139
+ message: `No route matched ${method} ${pathname}.`,
140
+ });
141
+ } catch (error) {
142
+ writeJson(response, 500, {
143
+ ok: false,
144
+ message: error instanceof Error ? error.message : String(error),
145
+ });
146
+ }
147
+ });
148
+
149
+ server.listen(port, host, () => {
150
+ console.log(`${brokerName} listening on http://${host}:${port}`);
151
+ });
@@ -6,6 +6,9 @@ export interface CloudBrokerStatus {
6
6
  version?: string;
7
7
  features: CloudBrokerFeature[];
8
8
  docsUrl?: string;
9
+ mode?: "scaffold" | "live";
10
+ oauthConfigured?: boolean;
11
+ message?: string;
9
12
  }
10
13
  export declare class CloudBrokerClient {
11
14
  private readonly config;
@@ -1 +1 @@
1
- {"version":3,"file":"brokerClient.js","sourceRoot":"","sources":["../../../src/cloud/brokerClient.ts"],"names":[],"mappings":";;;AAYA,SAAS,iBAAiB,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AACpC,CAAC;AAED,MAAa,iBAAiB;IACC;IAA7B,YAA6B,MAAyB;QAAzB,WAAM,GAAN,MAAM,CAAmB;IAAG,CAAC;IAE1D,IAAI,UAAU;QACZ,OAAO,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;IAClF,CAAC;IAED,IAAI,OAAO;QACT,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,OAAO,iBAAiB,CAAC,IAAI,CAAC,MAAM,CAAC,GAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,OAAO,IAAI,CAAC,OAAO,CAAoB,YAAY,EAAE;YACnD,MAAM,EAAE,KAAK;SACd,CAAC,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,OAAO,CAAI,IAAY,EAAE,IAAiB;QACtD,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxD,CAAC;QAED,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAE5E,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,EAAE;gBACrD,GAAG,IAAI;gBACP,OAAO,EAAE;oBACP,MAAM,EAAE,kBAAkB;oBAC1B,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM;wBACpB,CAAC,CAAC;4BACE,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE;yBAC9C;wBACH,CAAC,CAAC,EAAE,CAAC;oBACP,GAAG,IAAI,CAAC,OAAO;iBAChB;gBACD,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,gCAAgC,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;YAC5F,CAAC;YAED,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAO,CAAC;QACpC,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;CACF;AArDD,8CAqDC"}
1
+ {"version":3,"file":"brokerClient.js","sourceRoot":"","sources":["../../../src/cloud/brokerClient.ts"],"names":[],"mappings":";;;AAeA,SAAS,iBAAiB,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AACpC,CAAC;AAED,MAAa,iBAAiB;IACC;IAA7B,YAA6B,MAAyB;QAAzB,WAAM,GAAN,MAAM,CAAmB;IAAG,CAAC;IAE1D,IAAI,UAAU;QACZ,OAAO,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;IAClF,CAAC;IAED,IAAI,OAAO;QACT,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,OAAO,iBAAiB,CAAC,IAAI,CAAC,MAAM,CAAC,GAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,OAAO,IAAI,CAAC,OAAO,CAAoB,YAAY,EAAE;YACnD,MAAM,EAAE,KAAK;SACd,CAAC,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,OAAO,CAAI,IAAY,EAAE,IAAiB;QACtD,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxD,CAAC;QAED,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAE5E,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,EAAE;gBACrD,GAAG,IAAI;gBACP,OAAO,EAAE;oBACP,MAAM,EAAE,kBAAkB;oBAC1B,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM;wBACpB,CAAC,CAAC;4BACE,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE;yBAC9C;wBACH,CAAC,CAAC,EAAE,CAAC;oBACP,GAAG,IAAI,CAAC,OAAO;iBAChB;gBACD,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,gCAAgC,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;YAC5F,CAAC;YAED,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAO,CAAC;QACpC,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;CACF;AArDD,8CAqDC"}
@@ -1,3 +1,4 @@
1
+ import { CloudBrokerClient } from "../cloud/brokerClient";
1
2
  import type { SceneDefinition, SceneRunResult, ScenesPlatformConfig, TopologySnapshot, ValidationResult } from "../types";
2
3
  export declare function createDefaultUiConfig(): ScenesPlatformConfig;
3
4
  export declare function discoverForUi(configInput: Partial<ScenesPlatformConfig> | undefined): Promise<{
@@ -9,3 +10,9 @@ export declare function validateSceneForUi(configInput: Partial<ScenesPlatformCo
9
10
  snapshot: TopologySnapshot;
10
11
  }>;
11
12
  export declare function runTestForUi(configInput: Partial<ScenesPlatformConfig> | undefined, sceneInput: Partial<SceneDefinition>): Promise<SceneRunResult>;
13
+ export declare function checkBrokerForUi(configInput: Partial<ScenesPlatformConfig> | undefined): Promise<{
14
+ configured: boolean;
15
+ url?: string;
16
+ status?: Awaited<ReturnType<CloudBrokerClient["getStatus"]>>;
17
+ error?: string;
18
+ }>;
@@ -4,7 +4,9 @@ exports.createDefaultUiConfig = createDefaultUiConfig;
4
4
  exports.discoverForUi = discoverForUi;
5
5
  exports.validateSceneForUi = validateSceneForUi;
6
6
  exports.runTestForUi = runTestForUi;
7
+ exports.checkBrokerForUi = checkBrokerForUi;
7
8
  const config_1 = require("../config");
9
+ const brokerClient_1 = require("../cloud/brokerClient");
8
10
  const discoveryService_1 = require("../discoveryService");
9
11
  const logger_1 = require("../logger");
10
12
  const sceneRunner_1 = require("../sceneRunner");
@@ -49,4 +51,28 @@ async function runTestForUi(configInput, sceneInput) {
49
51
  const scene = (0, config_1.normalizeScene)(sceneInput);
50
52
  return services.sceneRunner.runTest(scene);
51
53
  }
54
+ async function checkBrokerForUi(configInput) {
55
+ const config = (0, config_1.normalizePlatformConfig)(configInput);
56
+ const client = new brokerClient_1.CloudBrokerClient(config.cloud.broker);
57
+ if (!client.configured) {
58
+ return {
59
+ configured: false,
60
+ };
61
+ }
62
+ try {
63
+ const status = await client.getStatus();
64
+ return {
65
+ configured: true,
66
+ url: client.baseUrl,
67
+ status,
68
+ };
69
+ }
70
+ catch (error) {
71
+ return {
72
+ configured: true,
73
+ url: client.baseUrl,
74
+ error: error instanceof Error ? error.message : String(error),
75
+ };
76
+ }
77
+ }
52
78
  //# sourceMappingURL=serverApi.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"serverApi.js","sourceRoot":"","sources":["../../../src/ui/serverApi.ts"],"names":[],"mappings":";;AAOA,sDAEC;AAmBD,sCAOC;AAED,gDAcC;AAED,oCAOC;AA5DD,sCAA6F;AAC7F,0DAAuD;AACvD,sCAAiE;AACjE,gDAA6C;AAC7C,8CAAgD;AAGhD,SAAgB,qBAAqB;IACnC,OAAO,IAAA,gCAAuB,EAAC,SAAS,CAAC,CAAC;AAC5C,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,WAAsD;IACjF,MAAM,MAAM,GAAG,IAAA,gCAAuB,EAAC,WAAW,CAAC,CAAC;IACpD,MAAM,SAAS,GAAG,IAAI,2BAAkB,EAAE,CAAC;IAC3C,MAAM,MAAM,GAAG,IAAI,yBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC;IACjF,MAAM,SAAS,GAAG,IAAA,4BAAe,EAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,gBAAgB,GAAG,IAAI,mCAAgB,CAAC,SAAS,CAAC,CAAC;IACzD,MAAM,WAAW,GAAG,IAAI,yBAAW,CAAC,gBAAgB,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;IAEzE,OAAO;QACL,MAAM;QACN,SAAS;QACT,gBAAgB;QAChB,WAAW;QACX,SAAS;KACV,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,aAAa,CACjC,WAAsD;IAEtD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,OAAO;QACL,QAAQ,EAAE,MAAM,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE;KACpD,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,kBAAkB,CACtC,WAAsD,EACtD,UAAoC;IAEpC,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC;IAC3D,MAAM,eAAe,GAAG,IAAA,uBAAc,EAAC,UAAU,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,IAAA,gCAAuB,EAAC,eAAe,EAAE,QAAQ,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC;IAE1F,OAAO;QACL,UAAU;QACV,eAAe;QACf,QAAQ;KACT,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,YAAY,CAChC,WAAsD,EACtD,UAAoC;IAEpC,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,IAAA,uBAAc,EAAC,UAAU,CAAC,CAAC;IACzC,OAAO,QAAQ,CAAC,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC7C,CAAC"}
1
+ {"version":3,"file":"serverApi.js","sourceRoot":"","sources":["../../../src/ui/serverApi.ts"],"names":[],"mappings":";;AAQA,sDAEC;AAmBD,sCAOC;AAED,gDAcC;AAED,oCAOC;AAED,4CA0BC;AAzFD,sCAA6F;AAC7F,wDAA0D;AAC1D,0DAAuD;AACvD,sCAAiE;AACjE,gDAA6C;AAC7C,8CAAgD;AAGhD,SAAgB,qBAAqB;IACnC,OAAO,IAAA,gCAAuB,EAAC,SAAS,CAAC,CAAC;AAC5C,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,WAAsD;IACjF,MAAM,MAAM,GAAG,IAAA,gCAAuB,EAAC,WAAW,CAAC,CAAC;IACpD,MAAM,SAAS,GAAG,IAAI,2BAAkB,EAAE,CAAC;IAC3C,MAAM,MAAM,GAAG,IAAI,yBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC;IACjF,MAAM,SAAS,GAAG,IAAA,4BAAe,EAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,gBAAgB,GAAG,IAAI,mCAAgB,CAAC,SAAS,CAAC,CAAC;IACzD,MAAM,WAAW,GAAG,IAAI,yBAAW,CAAC,gBAAgB,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;IAEzE,OAAO;QACL,MAAM;QACN,SAAS;QACT,gBAAgB;QAChB,WAAW;QACX,SAAS;KACV,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,aAAa,CACjC,WAAsD;IAEtD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,OAAO;QACL,QAAQ,EAAE,MAAM,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE;KACpD,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,kBAAkB,CACtC,WAAsD,EACtD,UAAoC;IAEpC,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC;IAC3D,MAAM,eAAe,GAAG,IAAA,uBAAc,EAAC,UAAU,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,IAAA,gCAAuB,EAAC,eAAe,EAAE,QAAQ,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC;IAE1F,OAAO;QACL,UAAU;QACV,eAAe;QACf,QAAQ;KACT,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,YAAY,CAChC,WAAsD,EACtD,UAAoC;IAEpC,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,IAAA,uBAAc,EAAC,UAAU,CAAC,CAAC;IACzC,OAAO,QAAQ,CAAC,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC7C,CAAC;AAEM,KAAK,UAAU,gBAAgB,CACpC,WAAsD;IAEtD,MAAM,MAAM,GAAG,IAAA,gCAAuB,EAAC,WAAW,CAAC,CAAC;IACpD,MAAM,MAAM,GAAG,IAAI,gCAAiB,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAE1D,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;QACvB,OAAO;YACL,UAAU,EAAE,KAAK;SAClB,CAAC;IACJ,CAAC;IAED,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC;QACxC,OAAO;YACL,UAAU,EAAE,IAAI;YAChB,GAAG,EAAE,MAAM,CAAC,OAAO;YACnB,MAAM;SACP,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO;YACL,UAAU,EAAE,IAAI;YAChB,GAAG,EAAE,MAAM,CAAC,OAAO;YACnB,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC9D,CAAC;IACJ,CAAC;AACH,CAAC"}
@@ -4,6 +4,8 @@ This project is intentionally local-first. For some Sonos favorites and playlist
4
4
 
5
5
  This document defines the shape of that future broker so the Homebridge plugin can support a `local_plus_cloud` mode without forcing the maintainer to host user tokens.
6
6
 
7
+ An initial self-hosted scaffold now lives under [broker/](../broker/README.md). It exposes the status endpoint and reserves the future route surface, but it does not implement Sonos OAuth or cloud playback yet.
8
+
7
9
  ## Design Goals
8
10
 
9
11
  - Keep the Homebridge plugin installable and useful with no cloud dependency.
@@ -27,6 +27,95 @@
27
27
  color: rgba(232, 242, 255, 0.88);
28
28
  }
29
29
 
30
+ .scene-mode-grid {
31
+ display: grid;
32
+ gap: 1rem;
33
+ grid-template-columns: minmax(280px, 1.1fr) minmax(320px, 1.3fr);
34
+ }
35
+
36
+ .scene-mode-panel {
37
+ border-radius: 1rem;
38
+ border: 1px solid rgba(22, 32, 51, 0.08);
39
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
40
+ background: white;
41
+ overflow: hidden;
42
+ }
43
+
44
+ .scene-mode-panel .card-header {
45
+ border-bottom: 1px solid rgba(22, 32, 51, 0.08);
46
+ background: rgba(255, 255, 255, 0.65);
47
+ }
48
+
49
+ .scene-mode-choice {
50
+ display: block;
51
+ border: 1px solid rgba(22, 32, 51, 0.1);
52
+ border-radius: 0.85rem;
53
+ padding: 0.85rem 0.95rem;
54
+ background: #fff;
55
+ cursor: pointer;
56
+ }
57
+
58
+ .scene-mode-choice.active {
59
+ border-color: rgba(10, 132, 255, 0.42);
60
+ background: rgba(10, 132, 255, 0.06);
61
+ }
62
+
63
+ .scene-mode-choice input {
64
+ margin-right: 0.65rem;
65
+ }
66
+
67
+ .scene-mode-choice-title {
68
+ color: var(--scene-ink);
69
+ font-weight: 600;
70
+ }
71
+
72
+ .scene-mode-choice-copy {
73
+ display: block;
74
+ color: #667085;
75
+ font-size: 0.9rem;
76
+ line-height: 1.45;
77
+ margin-top: 0.35rem;
78
+ }
79
+
80
+ .scene-cloud-settings {
81
+ display: grid;
82
+ gap: 1rem;
83
+ }
84
+
85
+ .scene-cloud-hidden {
86
+ display: none;
87
+ }
88
+
89
+ .scene-status-pill {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ gap: 0.45rem;
93
+ padding: 0.25rem 0.7rem;
94
+ border-radius: 999px;
95
+ font-size: 0.8rem;
96
+ font-weight: 600;
97
+ }
98
+
99
+ .scene-status-pill.local {
100
+ background: rgba(10, 132, 255, 0.08);
101
+ color: #0a84ff;
102
+ }
103
+
104
+ .scene-status-pill.pending {
105
+ background: rgba(245, 158, 11, 0.14);
106
+ color: #b45309;
107
+ }
108
+
109
+ .scene-status-pill.success {
110
+ background: rgba(46, 213, 115, 0.14);
111
+ color: #0f9f50;
112
+ }
113
+
114
+ .scene-status-pill.error {
115
+ background: rgba(239, 68, 68, 0.14);
116
+ color: #b42318;
117
+ }
118
+
30
119
  .scene-grid {
31
120
  display: grid;
32
121
  gap: 1rem;
@@ -236,6 +325,10 @@
236
325
  }
237
326
 
238
327
  @media (max-width: 960px) {
328
+ .scene-mode-grid {
329
+ grid-template-columns: 1fr;
330
+ }
331
+
239
332
  .scene-grid {
240
333
  grid-template-columns: 1fr;
241
334
  }
@@ -278,6 +371,80 @@
278
371
  </div>
279
372
  </section>
280
373
 
374
+ <section class="scene-mode-grid">
375
+ <section class="scene-mode-panel">
376
+ <div class="card-header d-flex justify-content-between align-items-center">
377
+ <div>
378
+ <strong>Execution Mode</strong>
379
+ <div class="scene-help">Choose whether scenes stay fully local or can use a self-hosted Sonos cloud broker later.</div>
380
+ </div>
381
+ <div id="cloud-mode-pill" class="scene-status-pill local">Local Only</div>
382
+ </div>
383
+ <div class="card-body d-grid gap-3">
384
+ <label class="scene-mode-choice" data-mode-choice="local_only">
385
+ <span class="d-flex align-items-start">
386
+ <input type="radio" name="cloud-mode" value="local_only">
387
+ <span>
388
+ <span class="scene-mode-choice-title">Local Only</span>
389
+ <span class="scene-mode-choice-copy">Best for line-in, grouping, TV, and favorites that already work over the local Sonos path. No external broker required.</span>
390
+ </span>
391
+ </span>
392
+ </label>
393
+ <label class="scene-mode-choice" data-mode-choice="local_plus_cloud">
394
+ <span class="d-flex align-items-start">
395
+ <input type="radio" name="cloud-mode" value="local_plus_cloud">
396
+ <span>
397
+ <span class="scene-mode-choice-title">Local + Cloud</span>
398
+ <span class="scene-mode-choice-copy">Future-ready mode for self-hosters who run their own Sonos cloud broker to support favorites and playlists that are not reliable locally.</span>
399
+ </span>
400
+ </span>
401
+ </label>
402
+ </div>
403
+ </section>
404
+
405
+ <section class="scene-mode-panel">
406
+ <div class="card-header d-flex justify-content-between align-items-center">
407
+ <div>
408
+ <strong>Cloud Broker</strong>
409
+ <div class="scene-help">Optional. Only needed if you choose Local + Cloud and run your own broker service.</div>
410
+ </div>
411
+ <button class="btn btn-sm btn-outline-primary" id="check-broker-button" type="button">Check Broker</button>
412
+ </div>
413
+ <div class="card-body scene-cloud-settings">
414
+ <div id="cloud-broker-copy" class="scene-help">Local Only is active. Broker settings are preserved, but they are not used until you switch modes.</div>
415
+ <div id="cloud-broker-fields" class="scene-cloud-hidden">
416
+ <div class="row g-3">
417
+ <div class="col-12">
418
+ <label class="form-label" for="broker-url">Broker URL</label>
419
+ <input class="form-control" id="broker-url" type="url" placeholder="https://sonos-broker.example.com">
420
+ </div>
421
+ <div class="col-md-8">
422
+ <label class="form-label" for="broker-api-key">Broker API Key</label>
423
+ <input class="form-control" id="broker-api-key" type="password" placeholder="Optional bearer token">
424
+ </div>
425
+ <div class="col-md-4">
426
+ <label class="form-label" for="broker-timeout-ms">Timeout (ms)</label>
427
+ <input class="form-control" id="broker-timeout-ms" type="number" min="1000">
428
+ </div>
429
+ <div class="col-md-6">
430
+ <label class="form-check">
431
+ <input class="form-check-input" id="broker-route-favorites" type="checkbox">
432
+ <span class="form-check-label">Route favorites through broker</span>
433
+ </label>
434
+ </div>
435
+ <div class="col-md-6">
436
+ <label class="form-check">
437
+ <input class="form-check-input" id="broker-route-playlists" type="checkbox">
438
+ <span class="form-check-label">Route playlists through broker</span>
439
+ </label>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ <div id="broker-status-output" class="scene-help">No broker check has been run yet.</div>
444
+ </div>
445
+ </section>
446
+ </section>
447
+
281
448
  <div class="scene-grid">
282
449
  <section class="card scene-card">
283
450
  <div class="card-header d-flex justify-content-between align-items-center">
@@ -398,6 +565,7 @@
398
565
  activeSceneId: null,
399
566
  validation: null,
400
567
  lastRun: null,
568
+ brokerStatus: null,
401
569
  };
402
570
 
403
571
  const elements = {
@@ -424,6 +592,18 @@
424
592
  validateButton: document.getElementById("validate-button"),
425
593
  testButton: document.getElementById("test-button"),
426
594
  saveHomebridgeButton: document.getElementById("save-homebridge-button"),
595
+ cloudModeInputs: Array.from(document.querySelectorAll("input[name='cloud-mode']")),
596
+ cloudModeChoices: Array.from(document.querySelectorAll("[data-mode-choice]")),
597
+ cloudModePill: document.getElementById("cloud-mode-pill"),
598
+ cloudBrokerCopy: document.getElementById("cloud-broker-copy"),
599
+ cloudBrokerFields: document.getElementById("cloud-broker-fields"),
600
+ brokerUrl: document.getElementById("broker-url"),
601
+ brokerApiKey: document.getElementById("broker-api-key"),
602
+ brokerTimeoutMs: document.getElementById("broker-timeout-ms"),
603
+ brokerRouteFavorites: document.getElementById("broker-route-favorites"),
604
+ brokerRoutePlaylists: document.getElementById("broker-route-playlists"),
605
+ brokerStatusOutput: document.getElementById("broker-status-output"),
606
+ checkBrokerButton: document.getElementById("check-broker-button"),
427
607
  saveSceneButton: document.getElementById("save-scene-button"),
428
608
  deleteSceneButton: document.getElementById("delete-scene-button"),
429
609
  newSceneButton: document.getElementById("new-scene-button"),
@@ -474,6 +654,21 @@
474
654
  };
475
655
  }
476
656
 
657
+ function serializeCloudConfig() {
658
+ const mode = elements.cloudModeInputs.find((input) => input.checked)?.value || "local_only";
659
+ state.config.cloud = {
660
+ mode,
661
+ broker: {
662
+ url: elements.brokerUrl.value.trim(),
663
+ apiKey: elements.brokerApiKey.value.trim(),
664
+ timeoutMs: Number(elements.brokerTimeoutMs.value || 8000),
665
+ routeFavorites: Boolean(elements.brokerRouteFavorites.checked),
666
+ routePlaylists: Boolean(elements.brokerRoutePlaylists.checked),
667
+ },
668
+ };
669
+ return state.config.cloud;
670
+ }
671
+
477
672
  function normalizeScene(scene) {
478
673
  const safeScene = scene || {};
479
674
  return {
@@ -527,6 +722,7 @@
527
722
  }
528
723
 
529
724
  function serializeDraft() {
725
+ serializeCloudConfig();
530
726
  const draft = clone(state.draft);
531
727
  draft.name = elements.sceneName.value.trim() || "New Scene";
532
728
  draft.householdId = elements.householdSelect.value;
@@ -605,6 +801,85 @@
605
801
  });
606
802
  }
607
803
 
804
+ function renderCloudModeControls() {
805
+ const cloud = state.config.cloud || {
806
+ mode: "local_only",
807
+ broker: {
808
+ url: "",
809
+ apiKey: "",
810
+ timeoutMs: 8000,
811
+ routeFavorites: true,
812
+ routePlaylists: true,
813
+ },
814
+ };
815
+
816
+ elements.cloudModeInputs.forEach((input) => {
817
+ input.checked = input.value === cloud.mode;
818
+ });
819
+
820
+ elements.cloudModeChoices.forEach((choice) => {
821
+ choice.classList.toggle("active", choice.dataset.modeChoice === cloud.mode);
822
+ });
823
+
824
+ const isHybrid = cloud.mode === "local_plus_cloud";
825
+ elements.cloudModePill.textContent = isHybrid ? "Local + Cloud" : "Local Only";
826
+ elements.cloudModePill.className = `scene-status-pill ${isHybrid ? "pending" : "local"}`;
827
+ elements.cloudBrokerFields.classList.toggle("scene-cloud-hidden", !isHybrid);
828
+ elements.cloudBrokerCopy.textContent = isHybrid
829
+ ? "Enter the URL for your self-hosted broker and optionally an API key. The plugin can then check that broker before cloud-backed scene support is wired in."
830
+ : "Local Only is active. Broker settings are preserved, but they are not used until you switch modes.";
831
+
832
+ elements.brokerUrl.value = cloud.broker?.url || "";
833
+ elements.brokerApiKey.value = cloud.broker?.apiKey || "";
834
+ elements.brokerTimeoutMs.value = cloud.broker?.timeoutMs ?? 8000;
835
+ elements.brokerRouteFavorites.checked = cloud.broker?.routeFavorites !== false;
836
+ elements.brokerRoutePlaylists.checked = cloud.broker?.routePlaylists !== false;
837
+
838
+ renderBrokerStatus();
839
+ }
840
+
841
+ function renderBrokerStatus() {
842
+ const cloud = state.config.cloud;
843
+ const status = state.brokerStatus;
844
+
845
+ if (cloud.mode !== "local_plus_cloud") {
846
+ elements.brokerStatusOutput.innerHTML = `<span class="scene-help">Broker checks are optional while Local Only is active.</span>`;
847
+ return;
848
+ }
849
+
850
+ if (!cloud.broker.url) {
851
+ elements.brokerStatusOutput.innerHTML = `<span class="scene-status-pill pending">Needs URL</span> <span class="scene-help">Enter your self-hosted broker URL, then click Check Broker.</span>`;
852
+ return;
853
+ }
854
+
855
+ if (!status) {
856
+ elements.brokerStatusOutput.innerHTML = `<span class="scene-status-pill pending">Not Checked</span> <span class="scene-help">Broker configured at ${cloud.broker.url}. Run a check to verify it responds.</span>`;
857
+ return;
858
+ }
859
+
860
+ if (status.error) {
861
+ elements.brokerStatusOutput.innerHTML = `<span class="scene-status-pill error">Broker Error</span> <span class="scene-help">${status.error}</span>`;
862
+ return;
863
+ }
864
+
865
+ const brokerStatus = status.status;
866
+ if (!brokerStatus) {
867
+ elements.brokerStatusOutput.innerHTML = `<span class="scene-status-pill pending">Unknown</span> <span class="scene-help">The broker returned no status payload.</span>`;
868
+ return;
869
+ }
870
+
871
+ const featureText = Array.isArray(brokerStatus.features) && brokerStatus.features.length > 0
872
+ ? brokerStatus.features.join(", ")
873
+ : "none reported";
874
+ const readiness = brokerStatus.oauthConfigured === false
875
+ ? "OAuth is not configured yet."
876
+ : brokerStatus.message || "Broker responded successfully.";
877
+ elements.brokerStatusOutput.innerHTML = `
878
+ <span class="scene-status-pill success">${brokerStatus.mode === "scaffold" ? "Scaffold" : "Ready"}</span>
879
+ <span class="scene-help"><strong>${brokerStatus.name || "Broker"}</strong> ${brokerStatus.version ? `v${brokerStatus.version}` : ""} at ${status.url || state.config.cloud.broker.url}. Features: ${featureText}. ${readiness}</span>
880
+ `;
881
+ }
882
+
608
883
  function renderHouseholdOptions() {
609
884
  const households = state.topology?.households || [];
610
885
  const currentId = state.draft.householdId || state.config.defaultHouseholdId || households[0]?.id || "";
@@ -832,6 +1107,7 @@
832
1107
  }
833
1108
 
834
1109
  function renderInputs() {
1110
+ renderCloudModeControls();
835
1111
  elements.sceneName.value = state.draft.name || "";
836
1112
  elements.sceneId.value = state.draft.id || "";
837
1113
  renderHouseholdOptions();
@@ -864,6 +1140,7 @@
864
1140
  }
865
1141
 
866
1142
  async function discover() {
1143
+ serializeCloudConfig();
867
1144
  homebridge.showSpinner();
868
1145
  try {
869
1146
  const result = await homebridge.request("/discover", { config: state.config });
@@ -947,11 +1224,38 @@
947
1224
  }
948
1225
 
949
1226
  async function saveToHomebridge() {
1227
+ serializeCloudConfig();
950
1228
  await persistConfig(false);
951
1229
  await homebridge.savePluginConfig();
952
1230
  homebridge.toast.success("Configuration saved to config.json.");
953
1231
  }
954
1232
 
1233
+ async function checkBroker() {
1234
+ serializeCloudConfig();
1235
+ if (!state.config.cloud.broker.url) {
1236
+ state.brokerStatus = {
1237
+ error: "Enter a broker URL first.",
1238
+ };
1239
+ render();
1240
+ return;
1241
+ }
1242
+
1243
+ homebridge.showSpinner();
1244
+ try {
1245
+ state.brokerStatus = await homebridge.request("/broker-status", {
1246
+ config: state.config,
1247
+ });
1248
+ render();
1249
+ } catch (error) {
1250
+ state.brokerStatus = {
1251
+ error: error.message || "Broker check failed.",
1252
+ };
1253
+ render();
1254
+ } finally {
1255
+ homebridge.hideSpinner();
1256
+ }
1257
+ }
1258
+
955
1259
  function bindEvents() {
956
1260
  elements.refreshButton.addEventListener("click", discover);
957
1261
  elements.validateButton.addEventListener("click", validateDraft);
@@ -960,6 +1264,29 @@
960
1264
  elements.deleteSceneButton.addEventListener("click", deleteScene);
961
1265
  elements.newSceneButton.addEventListener("click", newScene);
962
1266
  elements.saveHomebridgeButton.addEventListener("click", saveToHomebridge);
1267
+ elements.checkBrokerButton.addEventListener("click", checkBroker);
1268
+
1269
+ elements.cloudModeInputs.forEach((input) => {
1270
+ input.addEventListener("change", () => {
1271
+ serializeCloudConfig();
1272
+ state.brokerStatus = null;
1273
+ render();
1274
+ });
1275
+ });
1276
+
1277
+ [
1278
+ elements.brokerUrl,
1279
+ elements.brokerApiKey,
1280
+ elements.brokerTimeoutMs,
1281
+ elements.brokerRouteFavorites,
1282
+ elements.brokerRoutePlaylists,
1283
+ ].forEach((element) => {
1284
+ element.addEventListener("change", () => {
1285
+ serializeCloudConfig();
1286
+ state.brokerStatus = null;
1287
+ renderBrokerStatus();
1288
+ });
1289
+ });
963
1290
 
964
1291
  elements.householdSelect.addEventListener("change", () => {
965
1292
  serializeDraft();
@@ -993,6 +1320,7 @@
993
1320
  state.config = Object.assign({}, state.config, schemaConfig, {
994
1321
  scenes: state.config.scenes,
995
1322
  });
1323
+ state.brokerStatus = null;
996
1324
  });
997
1325
  }
998
1326
 
@@ -19,6 +19,7 @@ class UiServer extends HomebridgePluginUiServer {
19
19
  this.onRequest("/discover", this.handleDiscover.bind(this));
20
20
  this.onRequest("/validate-scene", this.handleValidateScene.bind(this));
21
21
  this.onRequest("/run-test", this.handleRunTest.bind(this));
22
+ this.onRequest("/broker-status", this.handleBrokerStatus.bind(this));
22
23
 
23
24
  this.ready();
24
25
  }
@@ -53,6 +54,14 @@ class UiServer extends HomebridgePluginUiServer {
53
54
  }
54
55
  }
55
56
 
57
+ async handleBrokerStatus(payload = {}) {
58
+ try {
59
+ return await this.serverApi.checkBrokerForUi(payload.config);
60
+ } catch (error) {
61
+ throw this.asRequestError(error, "Broker check failed");
62
+ }
63
+ }
64
+
56
65
  asRequestError(error, fallbackMessage) {
57
66
  if (error instanceof RequestError) {
58
67
  return error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-sonos-scenes",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Homebridge plugin for Sonos workflow scenes and orchestration.",
5
5
  "author": "applemanj",
6
6
  "homepage": "https://github.com/applemanj/homebridge-sonos-scenes#readme",
@@ -27,7 +27,8 @@
27
27
  "README.md",
28
28
  "LICENSE",
29
29
  "examples",
30
- "docs"
30
+ "docs",
31
+ "broker"
31
32
  ],
32
33
  "keywords": [
33
34
  "homebridge-plugin",