omni-notify-mcp 1.3.307 → 1.3.308

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.
@@ -101,9 +101,13 @@ function sameBoard(a, b) {
101
101
  function heldByWords(claim) {
102
102
  return `#${claim.storyId} is held by ${claim.name} (${String(claim.sessionId).replace(/^hold:/, "")}) — heartbeat ${claim.at}${claim.why === undefined ? "" : ` — why: ${claim.why}`}`;
103
103
  }
104
- /** The hold a conversation writes for a story: a `hold` in the conversation's own name, addressed by this machine and this process exactly as the board's engine writes one (#2838). */
105
- function holdOf(name, storyId, board, now) {
106
- return { storyId, name, sessionId: `hold:${hostname()}:${process.pid}`, at: boardStamp(now), kind: "hold", board };
104
+ /** The address every hold of this daemon is written under and released by (#2838). */
105
+ function holdAddress() {
106
+ return `hold:${hostname()}:${process.pid}`;
107
+ }
108
+ /** The hold a conversation writes for a story: a `hold` in the conversation's own name, addressed by this machine and this process exactly as the board's engine writes one (#2838). `drives` names the agent session this conversation runs, so a door can tell this hold from the work of the very session it holds for (#3850). */
109
+ function holdOf(name, storyId, board, now, drives) {
110
+ return { storyId, name, sessionId: holdAddress(), at: boardStamp(now), kind: "hold", board, drives };
107
111
  }
108
112
  /** The claims the file still holds at `now`, lapsed ones dropped exactly as every reader of the file drops them; a file that is not there is a machine that has claimed nothing. */
109
113
  function readClaims(file, now) {
@@ -207,12 +211,14 @@ function writeClaims(file, claims) {
207
211
  }
208
212
  }
209
213
  /** BN's conversation takes the story's hold: the holder's name IS the conversation's id, so every refusal and every door name this conversation. Another session's standing hold, or its fresh `taken` claim, refuses in the file's own words (#2838/#2040). */
210
- function holdStory(file, name, storyId, board, now) {
211
- const claim = holdOf(name, storyId, board, now);
214
+ function holdStory(file, name, storyId, board, now, drives) {
215
+ const claim = holdOf(name, storyId, board, now, drives);
212
216
  underClaimsLock(file, () => {
213
217
  const before = readClaims(file, now);
214
218
  const on = (c) => c.storyId === claim.storyId && sameBoard(c.board, board);
215
219
  const prior = before.find(on);
220
+ if (prior !== undefined && prior.sessionId === drives)
221
+ return;
216
222
  // The derived sessionId is the same for every conversation of one daemon, so a DIFFERENT conversation in this process is another holder too — its own name is what tells two of them apart, and neither may overwrite the other.
217
223
  const mine = prior !== undefined && prior.sessionId === claim.sessionId && prior.name === claim.name;
218
224
  if (prior !== undefined && !mine && (prior.kind === "hold" || claim.kind === "hold"))
@@ -222,7 +228,7 @@ function holdStory(file, name, storyId, board, now) {
222
228
  }
223
229
  /** BN drops the hold it wrote when the work on the story ends: only the claim under its own address AND its own name goes — a different conversation of this daemon shares the address and must keep its own claim (#2838). */
224
230
  function releaseStory(file, name, storyId, board, now) {
225
- const mine = holdOf(name, storyId, board, now).sessionId;
231
+ const mine = holdAddress();
226
232
  underClaimsLock(file, () => {
227
233
  const before = readClaims(file, now);
228
234
  const isMine = (c) => c.storyId === storyId && sameBoard(c.board, board) && c.sessionId === mine && c.name === name;
@@ -679,6 +685,7 @@ export class SessionDriver {
679
685
  let beat;
680
686
  if (hold !== undefined) {
681
687
  const held = hold;
688
+ this.refreshHold(c.id, held);
682
689
  beat = setInterval(() => this.refreshHold(c.id, held), HOLD_BEAT_MS);
683
690
  beat.unref();
684
691
  }
@@ -705,13 +712,13 @@ export class SessionDriver {
705
712
  // NULL-OK: field storyId is OPTIONAL and therefore it is ok to check for NULL
706
713
  if (storyId === undefined)
707
714
  return undefined;
708
- holdStory(claimsFile(), conversationId, storyId, project, Date.now());
715
+ holdStory(claimsFile(), conversationId, storyId, project, Date.now(), readConversation(conversationId).sessionId);
709
716
  return { storyId, board: project };
710
717
  }
711
718
  /** The hold is refreshed so it never lapses while the turn it belongs to runs. */
712
719
  refreshHold(conversationId, hold) {
713
720
  try {
714
- holdStory(claimsFile(), conversationId, hold.storyId, hold.board, Date.now());
721
+ holdStory(claimsFile(), conversationId, hold.storyId, hold.board, Date.now(), readConversation(conversationId).sessionId);
715
722
  }
716
723
  catch (err) {
717
724
  if (!(err instanceof Error))
@@ -0,0 +1,210 @@
1
+ // The foyer-pa RPC client: builds positional `application/json+protobuf` arrays (via foyer-codec),
2
+ // POSTs them to Google's Home Graph foyer endpoint, mints the bearer from the master token
3
+ // (gpsoauth), and retries once on 401/403 while advancing scope negotiation.
4
+ // Ported from coredevices/pebble-plugins `googlehome/src/foyer-client.ts` (which names the Kotlin
5
+ // `FoyerRpcClient`/`FoyerAuthImpl` as its origin).
6
+ import * as codec from './foyer-codec.js';
7
+ import { FoyerAuthError } from './gpsoauth.js';
8
+ const BASE = 'https://googlehomefoyer-pa.clients6.google.com/$rpc/google.internal.home.foyer.v1.';
9
+ const HOME_CONTROL_SERVICE = 'HomeControlService';
10
+ const STRUCTURES_SERVICE = 'StructuresService';
11
+ const AUTOMATION_SERVICE = 'AutomationService';
12
+ const CONTENT_TYPE = 'application/json+protobuf';
13
+ // The (app, client_sig, scope) triple that yields a bearer foyer-pa accepts.
14
+ const APP = 'com.google.android.apps.chromecast.app';
15
+ const CLIENT_SIG = '24bb24c05e47e0aefa68a58a766179d9b613a600';
16
+ const SCOPE_CANDIDATES = [
17
+ 'oauth2:https://www.googleapis.com/auth/homegraph',
18
+ 'oauth2:https://www.google.com/accounts/OAuthLogin',
19
+ ];
20
+ /** A refusal the calling agent should read: no structure, a routine that cannot be started, … */
21
+ export class GoogleHomeError extends Error {
22
+ }
23
+ export class FoyerHttpError extends GoogleHomeError {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.status = status;
28
+ }
29
+ }
30
+ /** Strips a leading XSSI guard line (`)]}'`) some clients6 endpoints prepend. */
31
+ export function stripXssiPrefix(body) {
32
+ const trimmed = body.replace(/^\s+/, '');
33
+ if (trimmed.startsWith(")]}'")) {
34
+ return trimmed.substring(4).replace(/^[\r\n]+/, '');
35
+ }
36
+ return body;
37
+ }
38
+ /** Mints + caches short-lived foyer bearers from the master token, with scope negotiation. */
39
+ export class FoyerAuth {
40
+ masterToken;
41
+ androidId;
42
+ oauth;
43
+ scopes;
44
+ app;
45
+ clientSig;
46
+ fallbackTtlSeconds;
47
+ expirySkewSeconds;
48
+ nowEpochSeconds;
49
+ cachedToken = null;
50
+ expiresAtEpoch = 0;
51
+ scopeIndex = 0;
52
+ constructor(masterToken, androidId, oauth, scopes = SCOPE_CANDIDATES, app = APP, clientSig = CLIENT_SIG, fallbackTtlSeconds = 55 * 60, expirySkewSeconds = 60, nowEpochSeconds = () => Math.floor(Date.now() / 1000)) {
53
+ this.masterToken = masterToken;
54
+ this.androidId = androidId;
55
+ this.oauth = oauth;
56
+ this.scopes = scopes;
57
+ this.app = app;
58
+ this.clientSig = clientSig;
59
+ this.fallbackTtlSeconds = fallbackTtlSeconds;
60
+ this.expirySkewSeconds = expirySkewSeconds;
61
+ this.nowEpochSeconds = nowEpochSeconds;
62
+ }
63
+ async bearer() {
64
+ const now = this.nowEpochSeconds();
65
+ if (this.cachedToken != null && now < this.expiresAtEpoch)
66
+ return this.cachedToken;
67
+ return this.mint(now);
68
+ }
69
+ invalidate() {
70
+ this.cachedToken = null;
71
+ this.expiresAtEpoch = 0;
72
+ this.scopeIndex = this.scopes.length === 0 ? 0 : (this.scopeIndex + 1) % this.scopes.length;
73
+ }
74
+ async mint(now) {
75
+ if (this.scopes.length === 0)
76
+ throw new FoyerAuthError('no foyer scope candidates configured; cannot mint a bearer');
77
+ let lastError = null;
78
+ for (let offset = 0; offset < this.scopes.length; offset++) {
79
+ const i = (this.scopeIndex + offset) % this.scopes.length;
80
+ try {
81
+ const token = await this.oauth.getAuthToken(this.masterToken, this.androidId, this.scopes[i], this.app, this.clientSig);
82
+ this.scopeIndex = i;
83
+ this.cachedToken = token.auth;
84
+ this.expiresAtEpoch = this.deadlineFor(token.expiryEpochSeconds, now);
85
+ return token.auth;
86
+ }
87
+ catch (e) {
88
+ lastError = e instanceof Error ? e : new FoyerAuthError(String(e));
89
+ }
90
+ }
91
+ throw lastError instanceof Error ? lastError : new FoyerAuthError('no foyer scope candidates could be minted');
92
+ }
93
+ deadlineFor(expiryEpochSeconds, now) {
94
+ const cap = now + this.fallbackTtlSeconds;
95
+ const base = expiryEpochSeconds != null && expiryEpochSeconds > now ? Math.min(expiryEpochSeconds, cap) : cap;
96
+ return base - this.expirySkewSeconds;
97
+ }
98
+ }
99
+ export class FoyerClient {
100
+ auth;
101
+ constructor(auth) {
102
+ this.auth = auth;
103
+ }
104
+ // Reads ---------------------------------------------------------------------------------------
105
+ async fetchHomeGraph() {
106
+ const resp = await this.rpc(STRUCTURES_SERVICE, 'GetHomeGraph', []);
107
+ return codec.parseGetHomeGraph(resp);
108
+ }
109
+ async getHomeGraph() {
110
+ const g = await this.fetchHomeGraph();
111
+ return { homes: g.homes, devices: g.devices };
112
+ }
113
+ async listRooms() {
114
+ return (await this.fetchHomeGraph()).rooms;
115
+ }
116
+ async getTraits(deviceIds) {
117
+ if (deviceIds.length === 0)
118
+ return [];
119
+ const resp = await this.rpc(HOME_CONTROL_SERVICE, 'GetTraits', codec.buildGetTraitsRequest(deviceIds));
120
+ return codec.parseGetTraits(resp);
121
+ }
122
+ // Control (existing devices only; no add/delete) ----------------------------------------------
123
+ async setOnOff(deviceId, agentId, partnerDeviceId, on) {
124
+ return this.update(deviceId, codec.buildOnOff(deviceId, agentId, partnerDeviceId, on), on);
125
+ }
126
+ async setBrightness(deviceId, agentId, partnerDeviceId, pct) {
127
+ return this.update(deviceId, codec.buildBrightness(deviceId, agentId, partnerDeviceId, clamp(pct, 0, 100)));
128
+ }
129
+ async setColorTemperature(deviceId, agentId, partnerDeviceId, kelvin) {
130
+ return this.update(deviceId, codec.buildColorTemperature(deviceId, agentId, partnerDeviceId, kelvin));
131
+ }
132
+ async setVolume(deviceId, agentId, partnerDeviceId, pct) {
133
+ return this.update(deviceId, codec.buildVolume(deviceId, agentId, partnerDeviceId, clamp(pct, 0, 100)));
134
+ }
135
+ async setMuted(deviceId, agentId, partnerDeviceId, muted) {
136
+ return this.update(deviceId, codec.buildMuted(deviceId, agentId, partnerDeviceId, muted));
137
+ }
138
+ async listAutomations() {
139
+ const structureId = (await this.fetchHomeGraph()).homes[0]?.id;
140
+ if (structureId == null)
141
+ throw new GoogleHomeError('no home/structure found; cannot list routines');
142
+ const resp = await this.rpc(AUTOMATION_SERVICE, 'ListAutomations', codec.buildListAutomationsRequest(structureId));
143
+ return codec.parseListAutomations(resp);
144
+ }
145
+ async runAutomation(automation) {
146
+ if (!automation.manuallyRunnable) {
147
+ throw new GoogleHomeError(`routine '${automation.name}' is condition/schedule-triggered and can't be started on demand`);
148
+ }
149
+ const structureId = (await this.fetchHomeGraph()).homes[0]?.id;
150
+ if (structureId == null)
151
+ throw new GoogleHomeError('no home/structure found; cannot run a routine');
152
+ const resp = await this.rpc(AUTOMATION_SERVICE, 'ExecuteAutomation', codec.buildExecuteAutomationRequest(structureId, automation.id));
153
+ return Array.isArray(resp) && resp.length > 0 && String(resp[0]) === '1';
154
+ }
155
+ // Internals -----------------------------------------------------------------------------------
156
+ async update(deviceId, body, fallbackOnOff) {
157
+ const resp = await this.rpc(HOME_CONTROL_SERVICE, 'UpdateTraits', body);
158
+ const echoed = codec.parseGetTraits(resp).find((s) => s.id === deviceId);
159
+ if (echoed)
160
+ return echoed;
161
+ const readback = (await this.getTraits([deviceId])).find((s) => s.id === deviceId);
162
+ if (readback)
163
+ return readback;
164
+ return {
165
+ id: deviceId,
166
+ online: true,
167
+ onOff: fallbackOnOff ?? null,
168
+ brightnessPct: null,
169
+ colorTemperatureK: null,
170
+ volumePct: null,
171
+ muted: null,
172
+ locked: null,
173
+ jammed: null,
174
+ thermostatMode: null,
175
+ setpointC: null,
176
+ setpointF: null,
177
+ ambientC: null,
178
+ temperatureUnit: null,
179
+ };
180
+ }
181
+ /** POSTs body to `<BASE><service>/<method>`; on 401/403 invalidates + retries once. */
182
+ async rpc(service, method, body) {
183
+ const url = `${BASE}${service}/${method}`;
184
+ const bodyJson = JSON.stringify(body);
185
+ let response = await this.post(url, bodyJson, await this.auth.bearer());
186
+ if (response.status === 401 || response.status === 403) {
187
+ this.auth.invalidate();
188
+ response = await this.post(url, bodyJson, await this.auth.bearer());
189
+ }
190
+ if (response.status < 200 || response.status > 299) {
191
+ throw new FoyerHttpError(response.status, `google home ${service}/${method} -> HTTP ${response.status}: ${response.body.substring(0, 300)}`);
192
+ }
193
+ return JSON.parse(stripXssiPrefix(response.body));
194
+ }
195
+ async post(url, bodyJson, bearer) {
196
+ const resp = await fetch(url, {
197
+ method: 'POST',
198
+ headers: {
199
+ Authorization: `Bearer ${bearer}`,
200
+ 'X-User-Agent': 'grpc-web-javascript/0.1',
201
+ 'Content-Type': CONTENT_TYPE,
202
+ },
203
+ body: bodyJson,
204
+ });
205
+ return { status: resp.status, body: await resp.text() };
206
+ }
207
+ }
208
+ function clamp(n, lo, hi) {
209
+ return Math.max(lo, Math.min(hi, n));
210
+ }
@@ -0,0 +1,318 @@
1
+ // Builds and parses the foyer-pa `application/json+protobuf` wire format.
2
+ //
3
+ // The service does NOT use JSON objects — every request/response body is a *positional* JSON array
4
+ // where protobuf field numbers map to array indices (1-based, with `null` holes for absent fields).
5
+ // Ported from coredevices/pebble-plugins `googlehome/src/foyer-codec.ts` (which names the Kotlin
6
+ // `FoyerCodec` as its origin); the field indices were derived from live captures.
7
+ //
8
+ // Value encoding (the scalar wrapper `[i0,i1,i2,i3,…]`): a value sits at a type-specific index:
9
+ // - int / float -> index 1: [null, <num>]
10
+ // - string / enum-> index 2: [null, null, "<str>"]
11
+ // - bool -> index 3: [null, null, null, <0|1>]
12
+ /** The type used for matching: user-assigned type when set, else hardware type. */
13
+ export function effectiveType(d) {
14
+ return d.assignedType && d.assignedType.trim() !== '' ? d.assignedType : d.type;
15
+ }
16
+ // -------------------------------------------------------------------------------------------------
17
+ // Null-safe positional-array accessors (out-of-range / wrong-typed slot -> null)
18
+ // -------------------------------------------------------------------------------------------------
19
+ function arr(x) {
20
+ return Array.isArray(x) ? x : null;
21
+ }
22
+ function at(a, i) {
23
+ return a != null && i >= 0 && i < a.length ? a[i] : null;
24
+ }
25
+ function str(x) {
26
+ return typeof x === 'string' ? x : null;
27
+ }
28
+ function boolAt(a, i) {
29
+ const e = at(a, i);
30
+ if (typeof e === 'boolean')
31
+ return e;
32
+ return e === 1;
33
+ }
34
+ function numAt(a, i) {
35
+ const e = at(a, i);
36
+ return typeof e === 'number' ? e : null;
37
+ }
38
+ function strAt(a, i) {
39
+ return str(at(a, i));
40
+ }
41
+ function arrAt(a, i) {
42
+ return arr(at(a, i));
43
+ }
44
+ // -------------------------------------------------------------------------------------------------
45
+ // Scalar wrappers. In JS there is no int/float distinction, so intWrapper is just [null, n].
46
+ // -------------------------------------------------------------------------------------------------
47
+ function intWrapper(n) {
48
+ return [null, n];
49
+ }
50
+ function boolWrapper(b) {
51
+ return [null, null, null, b ? 1 : 0];
52
+ }
53
+ /** Flattens a trait body `[[fieldName,<wrapper>],…]` into a `fieldName -> wrapper` map. */
54
+ function fieldWrappers(body) {
55
+ const out = new Map();
56
+ if (body == null)
57
+ return out;
58
+ for (const fieldEl of body) {
59
+ const field = arr(fieldEl);
60
+ const name = strAt(field, 0);
61
+ if (name == null)
62
+ continue;
63
+ out.set(name, arrAt(field, 1));
64
+ }
65
+ return out;
66
+ }
67
+ // -------------------------------------------------------------------------------------------------
68
+ // GetTraits
69
+ // -------------------------------------------------------------------------------------------------
70
+ /** Request body for `HomeControlService/GetTraits`: `[[["id1"],["id2"],…]]`. */
71
+ export function buildGetTraitsRequest(ids) {
72
+ return [ids.map((id) => [id])];
73
+ }
74
+ export function parseGetTraits(json) {
75
+ const results = arrAt(arr(json), 0);
76
+ if (results == null)
77
+ return [];
78
+ const out = [];
79
+ for (const result of results) {
80
+ const r = arr(result);
81
+ const id = strAt(arrAt(r, 0), 0);
82
+ if (id == null)
83
+ continue;
84
+ const traits = arrAt(r, 1);
85
+ const s = {
86
+ id,
87
+ online: false,
88
+ onOff: null,
89
+ brightnessPct: null,
90
+ colorTemperatureK: null,
91
+ volumePct: null,
92
+ muted: null,
93
+ locked: null,
94
+ jammed: null,
95
+ thermostatMode: null,
96
+ setpointC: null,
97
+ setpointF: null,
98
+ ambientC: null,
99
+ temperatureUnit: null,
100
+ };
101
+ if (traits != null) {
102
+ for (const entry of traits) {
103
+ const e = arr(entry);
104
+ const fields = fieldWrappers(arrAt(e, 1));
105
+ switch (strAt(e, 0)) {
106
+ case 'deviceStatus':
107
+ if (fields.has('online'))
108
+ s.online = boolAt(fields.get('online') ?? null, 3);
109
+ break;
110
+ case 'onOff':
111
+ if (fields.has('onOff'))
112
+ s.onOff = boolAt(fields.get('onOff') ?? null, 3);
113
+ break;
114
+ case 'brightness':
115
+ s.brightnessPct = numAt(fields.get('brightness') ?? null, 1);
116
+ break;
117
+ case 'color':
118
+ s.colorTemperatureK = numAt(fields.get('colorTemperature') ?? null, 1);
119
+ break;
120
+ case 'volume':
121
+ s.volumePct = numAt(fields.get('currentVolume') ?? null, 1);
122
+ if (fields.has('isMuted'))
123
+ s.muted = boolAt(fields.get('isMuted') ?? null, 3);
124
+ break;
125
+ case 'lockUnlock':
126
+ if (fields.has('isLocked'))
127
+ s.locked = boolAt(fields.get('isLocked') ?? null, 3);
128
+ if (fields.has('isJammed'))
129
+ s.jammed = boolAt(fields.get('isJammed') ?? null, 3);
130
+ break;
131
+ case 'temperatureSetting':
132
+ s.thermostatMode = strAt(fields.get('mode') ?? null, 2);
133
+ s.setpointC = numAt(fields.get('thermostatTemperatureSetpointC') ?? null, 1);
134
+ s.setpointF = numAt(fields.get('thermostatTemperatureSetpointF') ?? null, 1);
135
+ s.ambientC = numAt(fields.get('ambientAirTemperatureC') ?? null, 1);
136
+ // temperatureUnit lives in the trait's params block (index 2), one array deeper.
137
+ s.temperatureUnit = strAt(fieldWrappers(arrAt(arrAt(e, 2), 0)).get('temperatureUnit') ?? null, 2);
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ out.push(s);
143
+ }
144
+ return out;
145
+ }
146
+ // -------------------------------------------------------------------------------------------------
147
+ // UpdateTraits (control)
148
+ // -------------------------------------------------------------------------------------------------
149
+ export function buildUpdate(deviceId, agentId, partnerDeviceId, traitName, fieldName, wrapper) {
150
+ const trait = [traitName, [[fieldName, wrapper]]];
151
+ const deviceKey = [deviceId, [agentId, partnerDeviceId]];
152
+ const command = [deviceKey, [trait]];
153
+ return [[command]];
154
+ }
155
+ export function buildOnOff(deviceId, agentId, partnerDeviceId, on) {
156
+ return buildUpdate(deviceId, agentId, partnerDeviceId, 'onOff', 'onOff', boolWrapper(on));
157
+ }
158
+ export function buildBrightness(deviceId, agentId, partnerDeviceId, pct) {
159
+ return buildUpdate(deviceId, agentId, partnerDeviceId, 'brightness', 'brightness', intWrapper(pct));
160
+ }
161
+ export function buildColorTemperature(deviceId, agentId, partnerDeviceId, kelvin) {
162
+ return buildUpdate(deviceId, agentId, partnerDeviceId, 'color', 'colorTemperature', intWrapper(kelvin));
163
+ }
164
+ export function buildVolume(deviceId, agentId, partnerDeviceId, pct) {
165
+ return buildUpdate(deviceId, agentId, partnerDeviceId, 'volume', 'currentVolume', intWrapper(pct));
166
+ }
167
+ export function buildMuted(deviceId, agentId, partnerDeviceId, muted) {
168
+ return buildUpdate(deviceId, agentId, partnerDeviceId, 'volume', 'isMuted', boolWrapper(muted));
169
+ }
170
+ // -------------------------------------------------------------------------------------------------
171
+ // AutomationService (routines)
172
+ // -------------------------------------------------------------------------------------------------
173
+ /** `ListAutomations` request body: `[<structureId>]`. */
174
+ export function buildListAutomationsRequest(structureId) {
175
+ return [structureId];
176
+ }
177
+ /** `ExecuteAutomation` request body: `[<structureId>, <automationId>, null, 2]`. */
178
+ export function buildExecuteAutomationRequest(structureId, automationId) {
179
+ return [structureId, automationId, null, 2];
180
+ }
181
+ export function parseListAutomations(json) {
182
+ const list = arrAt(arr(json), 0);
183
+ if (list == null)
184
+ return [];
185
+ const out = [];
186
+ for (const recEl of list) {
187
+ const rec = arr(recEl);
188
+ const id = strAt(rec, 0);
189
+ const name = strAt(rec, 3);
190
+ if (id == null || name == null)
191
+ continue;
192
+ out.push({
193
+ id,
194
+ name,
195
+ manuallyRunnable: numAt(rec, 2) === 1,
196
+ starters: strAt(rec, 4),
197
+ actions: strAt(rec, 5),
198
+ triggerInput: strAt(rec, 10),
199
+ });
200
+ }
201
+ return out;
202
+ }
203
+ // -------------------------------------------------------------------------------------------------
204
+ // Capability derivation
205
+ // -------------------------------------------------------------------------------------------------
206
+ export function deriveCapabilities(traits) {
207
+ const caps = new Set();
208
+ for (const raw of traits) {
209
+ const suffix = raw.split('.').pop()?.toLowerCase() ?? '';
210
+ switch (suffix) {
211
+ case 'onoff':
212
+ caps.add('on_off');
213
+ break;
214
+ case 'brightness':
215
+ caps.add('brightness');
216
+ break;
217
+ case 'colorsetting':
218
+ caps.add('color_temperature');
219
+ caps.add('color');
220
+ break;
221
+ case 'volume':
222
+ caps.add('volume');
223
+ break;
224
+ case 'lockunlock':
225
+ caps.add('lock');
226
+ break;
227
+ case 'temperaturesetting':
228
+ case 'temperaturecontrol':
229
+ caps.add('thermostat');
230
+ break;
231
+ case 'transportcontrol':
232
+ case 'mediastate':
233
+ caps.add('media_transport');
234
+ break;
235
+ }
236
+ }
237
+ return caps;
238
+ }
239
+ // -------------------------------------------------------------------------------------------------
240
+ // GetHomeGraph (enumeration)
241
+ // -------------------------------------------------------------------------------------------------
242
+ export function parseGetHomeGraph(json) {
243
+ const homesArr = arrAt(arr(json), 1);
244
+ if (homesArr == null)
245
+ return { homes: [], devices: [], rooms: [] };
246
+ const homes = [];
247
+ const rooms = [];
248
+ const devices = [];
249
+ const deviceRoom = new Map();
250
+ for (const homeEl of homesArr) {
251
+ const home = arr(homeEl);
252
+ const homeId = strAt(home, 0);
253
+ if (homeId == null)
254
+ continue;
255
+ homes.push({ id: homeId, name: strAt(home, 1) ?? '' });
256
+ // Rooms: [5]
257
+ const roomsArr = arrAt(home, 5);
258
+ if (roomsArr != null) {
259
+ for (const roomEl of roomsArr) {
260
+ const room = arr(roomEl);
261
+ const roomId = strAt(room, 0);
262
+ if (roomId == null)
263
+ continue;
264
+ const roomName = strAt(room, 2) ?? '';
265
+ const roomType = strAt(arrAt(room, 3), 0) ?? '';
266
+ const memberIds = [];
267
+ const members = arrAt(room, 4);
268
+ if (members != null) {
269
+ for (const memberEl of members) {
270
+ // member = [[deviceId,[agentId,partnerId]]]
271
+ const devId = strAt(arrAt(arr(memberEl), 0), 0);
272
+ if (devId == null)
273
+ continue;
274
+ memberIds.push(devId);
275
+ deviceRoom.set(devId, roomName);
276
+ }
277
+ }
278
+ rooms.push({ id: roomId, name: roomName, type: roomType, deviceIds: memberIds });
279
+ }
280
+ }
281
+ // Devices: [6]
282
+ const devsArr = arrAt(home, 6);
283
+ if (devsArr != null) {
284
+ for (const devEl of devsArr) {
285
+ const rec = arr(devEl);
286
+ const key = arrAt(rec, 0); // [deviceId,[agentId,partnerId]]
287
+ if (key == null)
288
+ continue;
289
+ const devId = strAt(key, 0);
290
+ if (devId == null)
291
+ continue;
292
+ const agentPair = arrAt(key, 1);
293
+ const agentId = strAt(agentPair, 0);
294
+ const partnerDeviceId = strAt(agentPair, 1);
295
+ const name = strAt(rec, 3) ?? '';
296
+ const type = strAt(rec, 5) ?? '';
297
+ // Field [20] is the USER-ASSIGNED device type; it overrides hardware type [5] for intent.
298
+ const assignedType = strAt(arrAt(rec, 20), 0);
299
+ const traitsArr = arrAt(rec, 6);
300
+ const traits = traitsArr == null ? [] : traitsArr.map((t) => str(t)).filter((t) => t != null);
301
+ devices.push({
302
+ id: devId,
303
+ name,
304
+ type,
305
+ assignedType,
306
+ traits,
307
+ roomName: deviceRoom.get(devId) ?? null,
308
+ agentId,
309
+ partnerDeviceId,
310
+ capabilities: deriveCapabilities(traits),
311
+ });
312
+ }
313
+ }
314
+ }
315
+ // Room membership can appear after a device record within the same home; stamp again.
316
+ const stamped = devices.map((d) => d.roomName == null ? { ...d, roomName: deviceRoom.get(d.id) ?? null } : d);
317
+ return { homes, devices: stamped, rooms };
318
+ }