omni-notify-mcp 1.3.308 → 1.3.309

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,13 +101,9 @@ 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 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 };
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 };
111
107
  }
112
108
  /** 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. */
113
109
  function readClaims(file, now) {
@@ -211,14 +207,12 @@ function writeClaims(file, claims) {
211
207
  }
212
208
  }
213
209
  /** 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). */
214
- function holdStory(file, name, storyId, board, now, drives) {
215
- const claim = holdOf(name, storyId, board, now, drives);
210
+ function holdStory(file, name, storyId, board, now) {
211
+ const claim = holdOf(name, storyId, board, now);
216
212
  underClaimsLock(file, () => {
217
213
  const before = readClaims(file, now);
218
214
  const on = (c) => c.storyId === claim.storyId && sameBoard(c.board, board);
219
215
  const prior = before.find(on);
220
- if (prior !== undefined && prior.sessionId === drives)
221
- return;
222
216
  // 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.
223
217
  const mine = prior !== undefined && prior.sessionId === claim.sessionId && prior.name === claim.name;
224
218
  if (prior !== undefined && !mine && (prior.kind === "hold" || claim.kind === "hold"))
@@ -228,7 +222,7 @@ function holdStory(file, name, storyId, board, now, drives) {
228
222
  }
229
223
  /** 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). */
230
224
  function releaseStory(file, name, storyId, board, now) {
231
- const mine = holdAddress();
225
+ const mine = holdOf(name, storyId, board, now).sessionId;
232
226
  underClaimsLock(file, () => {
233
227
  const before = readClaims(file, now);
234
228
  const isMine = (c) => c.storyId === storyId && sameBoard(c.board, board) && c.sessionId === mine && c.name === name;
@@ -685,7 +679,6 @@ export class SessionDriver {
685
679
  let beat;
686
680
  if (hold !== undefined) {
687
681
  const held = hold;
688
- this.refreshHold(c.id, held);
689
682
  beat = setInterval(() => this.refreshHold(c.id, held), HOLD_BEAT_MS);
690
683
  beat.unref();
691
684
  }
@@ -712,13 +705,13 @@ export class SessionDriver {
712
705
  // NULL-OK: field storyId is OPTIONAL and therefore it is ok to check for NULL
713
706
  if (storyId === undefined)
714
707
  return undefined;
715
- holdStory(claimsFile(), conversationId, storyId, project, Date.now(), readConversation(conversationId).sessionId);
708
+ holdStory(claimsFile(), conversationId, storyId, project, Date.now());
716
709
  return { storyId, board: project };
717
710
  }
718
711
  /** The hold is refreshed so it never lapses while the turn it belongs to runs. */
719
712
  refreshHold(conversationId, hold) {
720
713
  try {
721
- holdStory(claimsFile(), conversationId, hold.storyId, hold.board, Date.now(), readConversation(conversationId).sessionId);
714
+ holdStory(claimsFile(), conversationId, hold.storyId, hold.board, Date.now());
722
715
  }
723
716
  catch (err) {
724
717
  if (!(err instanceof Error))
@@ -172,7 +172,7 @@ export function ownerBoundLaunch(invocation, ownerPid, cwd, guardDir) {
172
172
  }
173
173
  return {
174
174
  cmd: process.execPath,
175
- args: [path.join(guardDir, "ownerGuard.js"), String(ownerPid), encoded(invocation.cmd), encoded(JSON.stringify(invocation.args)), encoded(cwd)],
175
+ args: [path.join(guardDir, "ownerGuard.cjs"), String(ownerPid), encoded(invocation.cmd), encoded(JSON.stringify(invocation.args)), encoded(cwd)],
176
176
  };
177
177
  }
178
178
  // GATE: how long a CLI tree gets to exit on SIGTERM before it is SIGKILLed — POSIX only; Windows stops it at once
@@ -51,8 +51,30 @@ const PICK_BODY_BYTES = 4_096;
51
51
  function wholeRecord(record) {
52
52
  return record !== null && typeof record === "object" && Number.isInteger(record.port) && /^[0-9a-f]+$/.test(String(record.token)) && typeof record.label === "string" && typeof record.extension === "string" && record.pages !== null && typeof record.pages === "object";
53
53
  }
54
- /** The live windows serving `extension` on this machine, grouped by the workspace each runs in: label -> { page -> entry address carrying that window's key }. Two windows on one workspace answer with the newest record. */
55
- function workspacesServed(extension) {
54
+ const PUBLIC_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
55
+ /**
56
+ * THE ONE PRODUCER OF A PUBLIC ADDRESS BASE (#3867). The gateway puts the public name it served the request under on
57
+ * `x-forwarded-host`; every address a page is handed for a request that came through it is built on this base, never on
58
+ * the loopback bind. Undefined for a request made on this machine, whose addresses are the loopback ones. A name that
59
+ * is not a host name THROWS a TypeError.
60
+ */
61
+ function publicBase(request) {
62
+ const forwarded = request.headers["x-forwarded-host"];
63
+ if (forwarded === undefined) {
64
+ return undefined;
65
+ }
66
+ if (typeof forwarded !== "string" || !PUBLIC_NAME.test(forwarded)) {
67
+ throw new TypeError(`x-forwarded-host is not a host name: ${JSON.stringify(forwarded)}`);
68
+ }
69
+ return `https://${forwarded.toLowerCase()}`;
70
+ }
71
+ /**
72
+ * The live windows serving `extension` on this machine, grouped by the workspace each runs in: label -> { page -> entry
73
+ * address }. On this machine the address carries that window's key on the loopback port; through the gateway (`base`
74
+ * is its public address) it is the public address naming that window, which the gateway's own session admits. Two
75
+ * windows on one workspace answer with the newest record.
76
+ */
77
+ function workspacesServed(extension, base) {
56
78
  const directory = recordDirectory(extension);
57
79
  const served = new Map();
58
80
  if (!fs.existsSync(directory)) {
@@ -83,7 +105,8 @@ function workspacesServed(extension) {
83
105
  served.set(label, Object.fromEntries(Object.entries(record.pages).map(([page, info]) => {
84
106
  // NULL-OK: field search is OPTIONAL and therefore it is ok to check for NULL
85
107
  const search = info.search ?? "";
86
- return [page, `http://127.0.0.1:${record.port}/${encodeURIComponent(page)}/${search}${search === "" ? "?" : "&"}${TOKEN_PARAMETER}=${record.token}`];
108
+ const at = `/${encodeURIComponent(page)}/${search}${search === "" ? "?" : "&"}`;
109
+ return [page, base === undefined ? `http://127.0.0.1:${record.port}${at}${TOKEN_PARAMETER}=${record.token}` : `${base}${at}window=${record.pid}`];
87
110
  })));
88
111
  }
89
112
  return served;
@@ -703,6 +726,16 @@ class WebHost {
703
726
  return send(response, 403, MIME[".txt"], "this host serves 127.0.0.1 only");
704
727
  }
705
728
  const url = new URL(request.url, `http://127.0.0.1:${this.port}`);
729
+ let base;
730
+ try {
731
+ base = publicBase(request);
732
+ }
733
+ catch (err) {
734
+ if (!(err instanceof TypeError))
735
+ throw err;
736
+ // OWNER: this request's trigger root — a gateway name that is not a host name is answered 400 with the reason, and no address is built on it.
737
+ return send(response, 400, MIME[".txt"], err.message);
738
+ }
706
739
  if (url.searchParams.get(TOKEN_PARAMETER) === PROCESS.token) {
707
740
  url.searchParams.delete(TOKEN_PARAMETER);
708
741
  return send(response, 302, MIME[".txt"], "", {
@@ -727,7 +760,7 @@ class WebHost {
727
760
  let workspaces;
728
761
  let current;
729
762
  try {
730
- workspaces = Object.fromEntries([...this.workspaces()].sort(([a], [b]) => a.localeCompare(b)));
763
+ workspaces = Object.fromEntries([...this.workspaces(base)].sort(([a], [b]) => a.localeCompare(b)));
731
764
  // NULL-OK: field at is OPTIONAL and therefore it is ok to check for NULL
732
765
  current = this.currentOf(new URLSearchParams(url.searchParams.get("at") ?? ""));
733
766
  }
@@ -763,7 +796,7 @@ class WebHost {
763
796
  return this.serveFile(url.pathname.slice("/fs".length), response);
764
797
  }
765
798
  if (url.pathname === "/") {
766
- return send(response, 200, MIME[".html"], indexPage(this), { "Cache-Control": "no-store" });
799
+ return send(response, 200, MIME[".html"], indexPage(this, base), { "Cache-Control": "no-store" });
767
800
  }
768
801
  const [, first, rest] = /^\/([^/]*)\/?(.*)$/.exec(url.pathname);
769
802
  const page = this.pages.get(decodeURIComponent(first));
@@ -778,13 +811,13 @@ class WebHost {
778
811
  }
779
812
  const home = url.searchParams.get("bullseye-peek");
780
813
  if (home !== null && ROSTER_EXTENSIONS.has(this.extension)) {
781
- return send(response, 200, MIME[".html"], peekedRosterPage(this.label, home, workspacesServed(this.extension).get(home)?.[page.name]), { "Cache-Control": "no-store" });
814
+ return send(response, 200, MIME[".html"], peekedRosterPage(this.label, home, workspacesServed(this.extension, base).get(home)?.[page.name]), { "Cache-Control": "no-store" });
782
815
  }
783
816
  return this.serveDocument(page, response);
784
817
  }
785
- /** The workspaces a page of this host can switch to: label -> { page -> address }. A window's host lists every live window serving the same extension. */
786
- workspaces() {
787
- return workspacesServed(this.extension);
818
+ /** The workspaces a page of this host can switch to: label -> { page -> address }, built on `base` when the request came through the gateway. A window's host lists every live window serving the same extension. */
819
+ workspaces(base) {
820
+ return workspacesServed(this.extension, base);
788
821
  }
789
822
  /** The workspace the page asking is on, from the address it opened (`at`). A window's host is on its own window's workspace whatever the page asked. */
790
823
  currentOf() {
@@ -1050,9 +1083,9 @@ class ServiceHost extends WebHost {
1050
1083
  }
1051
1084
  }
1052
1085
  }
1053
- function indexPage(host) {
1086
+ function indexPage(host, base) {
1054
1087
  const rows = [...host.pages.values()]
1055
- .map((page) => `<li><a href="${escapeAttribute(page.url)}">${page.live ? "🟢" : "⚪"} ${escapeAttribute(page.title)}</a></li>`)
1088
+ .map((page) => `<li><a href="${escapeAttribute(base === undefined ? page.url : `${base}/${encodeURIComponent(page.name)}/${page.search}`)}">${page.live ? "🟢" : "⚪"} ${escapeAttribute(page.title)}</a></li>`)
1056
1089
  .join("");
1057
1090
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeAttribute(host.extension)}</title></head>
1058
1091
  <body style="font:14px system-ui,sans-serif;padding:24px;background:#1e1e1e;color:#ccc"><div>${escapeAttribute(host.extension)} · ${escapeAttribute(host.label)}</div><ul>${rows}</ul></body></html>`;
@@ -1,5 +1,60 @@
1
1
  "use strict";
2
2
  const PICKER_SOURCE = String.raw `
3
+ var BASE_CSS = [
4
+ "@media (max-width: 480px) {",
5
+ " html { -webkit-text-size-adjust: 100%; }",
6
+ " :root { --vscode-font-size: 14px !important; }",
7
+ " body { font-size: 14px; }",
8
+ " *, *::before, *::after { box-sizing: border-box; }",
9
+ " .filter-group, .be-seg { flex-wrap: wrap; max-width: 100%; }",
10
+ " table { display: block; max-width: 100%; overflow-x: auto; }",
11
+ " body { overflow-wrap: break-word; }",
12
+ " img, video, canvas { max-width: 100%; }",
13
+ " pre { max-width: 100%; overflow-x: auto; }",
14
+ " :where(textarea, input:not([type=checkbox]):not([type=radio]):not([type=range])) { font-size: 16px; }",
15
+ " :where(button:not([class*=be-]), select:not([class*=be-])) { min-height: 32px; }",
16
+ "}"
17
+ ].join(" ");
18
+
19
+ var MIN_PHONE_FONT = 12;
20
+ var floorTimer = 0;
21
+
22
+ function floorFonts() {
23
+ floorTimer = 0;
24
+ document.body.querySelectorAll("*").forEach(function (el) {
25
+ if (parseFloat(getComputedStyle(el).fontSize) < MIN_PHONE_FONT) {
26
+ el.style.setProperty("font-size", MIN_PHONE_FONT + "px", "important");
27
+ }
28
+ });
29
+ }
30
+
31
+ function floorFontsSoon() {
32
+ if (floorTimer === 0) {
33
+ floorTimer = setTimeout(floorFonts, 300);
34
+ }
35
+ }
36
+
37
+ function baseStyles() {
38
+ if (document.getElementById("__bullseye-base") !== null) {
39
+ return;
40
+ }
41
+ var style = document.createElement("style");
42
+ style.id = "__bullseye-base";
43
+ style.textContent = BASE_CSS;
44
+ document.head.insertBefore(style, document.head.firstChild);
45
+ if (window.matchMedia("(max-width: 480px)").matches) {
46
+ var watch = function () {
47
+ new MutationObserver(floorFontsSoon).observe(document.body, { childList: true, subtree: true });
48
+ floorFontsSoon();
49
+ };
50
+ if (document.readyState === "loading") {
51
+ document.addEventListener("DOMContentLoaded", watch);
52
+ } else {
53
+ watch();
54
+ }
55
+ }
56
+ }
57
+
3
58
  function notice(id, text) {
4
59
  if (document.getElementById(id) !== null) {
5
60
  return;
@@ -27,7 +82,7 @@ const PICKER_SOURCE = String.raw `
27
82
  function workspaceBox() {
28
83
  var box = document.createElement("div");
29
84
  box.id = "bullseye-workspace";
30
- box.style.cssText = "display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;align-items:center;box-sizing:border-box;min-height:24px;font:12px system-ui,sans-serif;" + barColours + ";border-bottom:1px solid #454545;padding:2px 8px;pointer-events:auto";
85
+ box.style.cssText = "display:flex;flex-wrap:nowrap;justify-content:flex-end;gap:8px;align-items:center;box-sizing:border-box;min-height:24px;max-width:100%;overflow:hidden;font:12px system-ui,sans-serif;" + barColours + ";border-bottom:1px solid #454545;padding:2px 8px;pointer-events:auto";
31
86
  return box;
32
87
  }
33
88
 
@@ -37,10 +92,11 @@ const PICKER_SOURCE = String.raw `
37
92
 
38
93
  function choose(text, labels, current, onChange, emptyText) {
39
94
  var holder = document.createElement("label");
95
+ holder.style.cssText = "min-width:0;white-space:nowrap";
40
96
  holder.textContent = text;
41
97
  var select = document.createElement("select");
42
98
  select.setAttribute("aria-label", text.trim());
43
- select.style.cssText = "font:inherit;" + barColours + ";color-scheme:dark;border:0";
99
+ select.style.cssText = "font:inherit;" + barColours + ";color-scheme:dark;border:0;min-width:0;max-width:42vw;text-overflow:ellipsis";
44
100
  labels.forEach(function (name) {
45
101
  var option = document.createElement("option");
46
102
  option.style.cssText = name === current ? "background:#094771;color:#fff;font-weight:600" : barColours;
@@ -136,6 +192,7 @@ const PICKER_SOURCE = String.raw `
136
192
  function peekBadge(served, labels) {
137
193
  var box = workspaceBox();
138
194
  box.style.background = "#5a3d00";
195
+ box.style.flexWrap = "wrap";
139
196
  box.style.color = "#fff";
140
197
  var words = document.createElement("span");
141
198
  words.textContent = "👁 Peeking at " + served.current + " — look only: nothing here can start or change work";
@@ -255,6 +312,7 @@ const SHIM_SOURCE = String.raw `(function () {
255
312
  };
256
313
 
257
314
  function start() {
315
+ baseStyles();
258
316
  open();
259
317
  workspacePicker();
260
318
  }
@@ -270,6 +328,7 @@ const PICKER_PAGE_SOURCE = String.raw `(function () {
270
328
  var page = document.currentScript.getAttribute("data-page");
271
329
  var peekable = false;
272
330
  ` + PICKER_SOURCE + String.raw `
331
+ baseStyles();
273
332
  if (document.readyState === "loading") {
274
333
  document.addEventListener("DOMContentLoaded", workspacePicker);
275
334
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.3.308",
3
+ "version": "1.3.309",
4
4
  "description": "Reach your AI agents from anywhere — and let them reach you. One stdio process serves the whole tool surface to Claude Code, GitHub Copilot Chat and Codex: desktop, Telegram, Slack, ntfy, Discord, Teams and SMS are configured in one file, and agents notify you when long work finishes or a decision is needed, ask you a question and wait for your answer, and receive and reply to the messages you send them — with Do Not Disturb and idle detection deciding what actually gets through.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,210 +0,0 @@
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
- }