omni-notify-mcp 1.3.306 → 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.
- package/dist/daemon.js +26 -6
- package/dist/gateway/notifyDoor.js +28 -2
- package/dist/gateway/sessionDriver.js +15 -8
- package/dist/googlehome/foyer-client.js +210 -0
- package/dist/googlehome/foyer-codec.js +318 -0
- package/dist/googlehome/gpsoauth.js +119 -0
- package/dist/googlehome/index.js +267 -0
- package/dist/notify.js +12 -3
- package/dist/shared/webhost/originHandover.js +480 -0
- package/package.json +1 -1
- package/scripts/shared/ownerGuard.cs +665 -0
- package/scripts/shared/ownerGuard.ps1 +31 -0
package/dist/daemon.js
CHANGED
|
@@ -13,8 +13,9 @@ import { spawn } from "child_process";
|
|
|
13
13
|
import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from "fs";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
|
-
import { ALWAYS_ON_PATH, CONFIG_DIR, PACKAGE_VERSION, startNotifyServer } from "./notify.js";
|
|
16
|
+
import { ALWAYS_ON_PATH, CONFIG_DIR, PACKAGE_VERSION, startNotifyServer, yieldGateway } from "./notify.js";
|
|
17
17
|
import { startNotifyDoor } from "./gateway/notifyDoor.js";
|
|
18
|
+
import { announceTakeover } from "./shared/webhost/originHandover.js";
|
|
18
19
|
import { TunnelRunner } from "./gateway/tunnel.js";
|
|
19
20
|
const HEARTBEAT_MS = 10_000;
|
|
20
21
|
function beat(pid = process.pid) {
|
|
@@ -70,14 +71,22 @@ if (stoppedByHand()) {
|
|
|
70
71
|
console.log("[BOOT] BullseyeNotify always-on — a Stop was pressed on this machine; nothing started, and nothing beats until Start is pressed in the panel.");
|
|
71
72
|
process.exit(0);
|
|
72
73
|
}
|
|
74
|
+
// The shared hand-over (src/shared/webhost/originHandover.js ensureResidentDoor) puts a later release on the chat door: the new build is started with --takeover over an IPC channel, says it waits, and binds once this build lets the door go.
|
|
75
|
+
const takeover = process.argv.includes("--takeover");
|
|
76
|
+
/** Whether this process writes the heartbeat: the next release only from the moment it holds the chat door, so a takeover that never lands leaves the serving build's beat alone; and a build that let the door go no longer, so the release that took it is the one beat. */
|
|
77
|
+
let beating = !takeover;
|
|
73
78
|
// The server's own timers are unref'd because a bridge lives as long as its stdin; this process lives until it is stopped.
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
if (beating)
|
|
80
|
+
beat();
|
|
81
|
+
/** Each tick reads the Stop first, beating or not: a press in a window that may not end this process — the boot task's, in session 0, or one still draining its gateway after a hand-over — stops it all the same, within one tick. */
|
|
76
82
|
const beatTimer = setInterval(() => {
|
|
77
83
|
if (stoppedByHand())
|
|
78
84
|
process.exit(0);
|
|
79
|
-
|
|
85
|
+
if (beating)
|
|
86
|
+
beat();
|
|
80
87
|
}, HEARTBEAT_MS);
|
|
88
|
+
/** Settles once the chat door has closed after the next release's `POST /shutdown`; set only then, when that release is already running and waiting for the port, so this process starts no successor. */
|
|
89
|
+
let handedOver;
|
|
81
90
|
// Only the daemon that owns the gateway port runs the connector and publishes its state: a dormant one has nothing for it to reach.
|
|
82
91
|
const tunnel = new TunnelRunner();
|
|
83
92
|
startNotifyServer({
|
|
@@ -86,9 +95,20 @@ startNotifyServer({
|
|
|
86
95
|
tunnel.beat(port);
|
|
87
96
|
setInterval(() => tunnel.beat(port), HEARTBEAT_MS);
|
|
88
97
|
},
|
|
89
|
-
onSuperseded: replaceWithInstalledBuild,
|
|
98
|
+
onSuperseded: () => (handedOver === undefined ? replaceWithInstalledBuild() : void handedOver.then(() => process.exit(0))),
|
|
99
|
+
});
|
|
100
|
+
startNotifyDoor(() => { beating = true; beat(); }, undefined, {
|
|
101
|
+
version: PACKAGE_VERSION,
|
|
102
|
+
takeover,
|
|
103
|
+
onShutdown: (closed) => {
|
|
104
|
+
beating = false;
|
|
105
|
+
handedOver = closed;
|
|
106
|
+
yieldGateway();
|
|
107
|
+
},
|
|
108
|
+
onTakeoverGaveUp: () => process.exit(1),
|
|
90
109
|
});
|
|
91
|
-
|
|
110
|
+
if (takeover)
|
|
111
|
+
announceTakeover();
|
|
92
112
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
93
113
|
process.on(signal, () => process.exit(0));
|
|
94
114
|
}
|
|
@@ -23,11 +23,14 @@
|
|
|
23
23
|
import { createServer, request as httpRequest } from "http";
|
|
24
24
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
25
25
|
import { createMcpServer, sessionIdentity } from "../notify.js";
|
|
26
|
+
import { answerShutdown, healthBody, TAKEOVER_BIND } from "../shared/webhost/originHandover.js";
|
|
26
27
|
/** BN's own port block: 7470 is the gateway, so the chat tools take the next free one. */
|
|
27
28
|
// GATE: the door's port may be moved ONLY under the test gate, so a suite drives a real always-on process on a port it owns; without the gate it is 7475 and no environment can redirect it. vscode-extension/alwaysOn.js probes the same rule.
|
|
28
29
|
export const NOTIFY_DOOR_PORT = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1" && Number(process.env.NOTIFY_DOOR_PORT) > 0 ? Number(process.env.NOTIFY_DOOR_PORT) : 7475;
|
|
29
30
|
export const NOTIFY_DOOR_PATH = "/mcp";
|
|
30
31
|
const RETRY_MS = 5_000;
|
|
32
|
+
/** The name the always-on process answers `/health` with: the `server` the boot task's supervisor hands BN's port over by (vscode-extension/originSupervisor.js ORIGINS). */
|
|
33
|
+
export const NOTIFY_SERVER = "bullseye-notify";
|
|
31
34
|
function headerOf(req, name) {
|
|
32
35
|
const value = req.headers[name];
|
|
33
36
|
const one = Array.isArray(value) ? value[0] : value;
|
|
@@ -76,18 +79,32 @@ function notifyDoorAnswers(port) {
|
|
|
76
79
|
* stated, because then notify's chat tools are unreachable machine-wide and nothing else would say so.
|
|
77
80
|
* Never rejects.
|
|
78
81
|
*/
|
|
79
|
-
export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT) {
|
|
82
|
+
export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT, resident) {
|
|
80
83
|
let bound = false;
|
|
81
84
|
let attempting = false;
|
|
85
|
+
let stopping = false;
|
|
82
86
|
let lastCause = "";
|
|
83
87
|
let listener = null;
|
|
88
|
+
const giveUpAt = Date.now() + TAKEOVER_BIND.giveUpMs;
|
|
84
89
|
const say = (line) => { process.stderr.write(`[notify] ${line}\n`); };
|
|
85
90
|
const attempt = () => {
|
|
86
|
-
if (bound || attempting)
|
|
91
|
+
if (bound || attempting || stopping)
|
|
87
92
|
return;
|
|
88
93
|
attempting = true;
|
|
89
94
|
const server = listener = createServer((req, res) => {
|
|
90
95
|
const path = new URL(req.url ?? "/", "http://door").pathname;
|
|
96
|
+
if (resident !== undefined && path === "/health") {
|
|
97
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
98
|
+
res.end(healthBody(NOTIFY_SERVER, port, resident.version));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (resident !== undefined && answerShutdown(req, res, server, NOTIFY_SERVER, port, resident.version, (closed) => {
|
|
102
|
+
stopping = true;
|
|
103
|
+
bound = false;
|
|
104
|
+
say(`the next release asked for the chat door on ${port}; build ${resident.version} lets it go`);
|
|
105
|
+
resident.onShutdown(closed);
|
|
106
|
+
}))
|
|
107
|
+
return;
|
|
91
108
|
if (path !== NOTIFY_DOOR_PATH) {
|
|
92
109
|
refuse(res, 404, `the notify MCP door answers on ${NOTIFY_DOOR_PATH}`);
|
|
93
110
|
return;
|
|
@@ -113,6 +130,15 @@ export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT) {
|
|
|
113
130
|
say(`the chat door on ${port} stopped serving (${cause}); rebinding`);
|
|
114
131
|
return;
|
|
115
132
|
}
|
|
133
|
+
if (cause === "EADDRINUSE" && resident?.takeover === true) {
|
|
134
|
+
if (Date.now() >= giveUpAt) {
|
|
135
|
+
say(`takeover gave up after ${TAKEOVER_BIND.giveUpMs / 1000}s: the chat door on ${port} was never let go by the release serving it, so build ${resident.version} serves nothing and that release keeps answering`);
|
|
136
|
+
resident.onTakeoverGaveUp();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setTimeout(attempt, TAKEOVER_BIND.retryMs);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
116
142
|
if (cause === lastCause)
|
|
117
143
|
return;
|
|
118
144
|
lastCause = cause;
|
|
@@ -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
|
|
105
|
-
function
|
|
106
|
-
return
|
|
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 =
|
|
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
|
+
}
|