omni-notify-mcp 1.3.289 → 1.3.290
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/package.json +1 -1
- package/dist/googlehome/foyer-client.js +0 -210
- package/dist/googlehome/foyer-codec.js +0 -318
- package/dist/googlehome/gpsoauth.js +0 -119
- package/dist/googlehome/index.js +0 -267
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.290",
|
|
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
|
-
}
|
|
@@ -1,318 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
// gpsoauth against `https://android.clients.google.com/auth` — the sign-in the Google Home Android
|
|
2
|
-
// app itself uses. Ported from coredevices/pebble-plugins `googlehome/src/gpsoauth.ts` (which names
|
|
3
|
-
// the Kotlin `GpsOAuthCodec`/`GpsOAuthClient` as its origin); the foyer Home Assistant integration
|
|
4
|
-
// carries the same protocol in `custom_components/googlehome_foyer/api.py`.
|
|
5
|
-
//
|
|
6
|
-
// The endpoint speaks a picky, non-JSON protocol:
|
|
7
|
-
// - Request body is `application/x-www-form-urlencoded`.
|
|
8
|
-
// - Response is newline-delimited `Key=Value` text (parsed by parseAuthResponse).
|
|
9
|
-
// `EncryptedPasswd` carries the master token verbatim (nothing is encrypted).
|
|
10
|
-
export const AUTH_URL = 'https://android.clients.google.com/auth';
|
|
11
|
-
export const DEFAULT_ANDROID_ID = '0123456789abcdef';
|
|
12
|
-
// The master token identifies the account, so any syntactically-valid Email works.
|
|
13
|
-
export const PLACEHOLDER_EMAIL = 'owner@example.com';
|
|
14
|
-
export const PLAY_SERVICES_VERSION = '240913000';
|
|
15
|
-
export const AC2DM_SERVICE = 'ac2dm';
|
|
16
|
-
// GMS core signing cert used for the ac2dm master-token exchange (not the per-app chromecast sig).
|
|
17
|
-
export const GMS_CLIENT_SIG = '38918a453d07199354f8b19af05ec6562ced5788';
|
|
18
|
-
export class FoyerAuthError extends Error {
|
|
19
|
-
}
|
|
20
|
-
/** ac2dm oauth_token -> master token exchange form. androidId must match the later getAuthToken. */
|
|
21
|
-
export function buildExchangeForm(oauthToken, androidId, email = PLACEHOLDER_EMAIL) {
|
|
22
|
-
return {
|
|
23
|
-
accountType: 'HOSTED_OR_GOOGLE',
|
|
24
|
-
Email: email,
|
|
25
|
-
has_permission: '1',
|
|
26
|
-
add_account: '1',
|
|
27
|
-
ACCESS_TOKEN: '1',
|
|
28
|
-
Token: oauthToken,
|
|
29
|
-
service: AC2DM_SERVICE,
|
|
30
|
-
source: 'android',
|
|
31
|
-
androidId,
|
|
32
|
-
device_country: 'us',
|
|
33
|
-
operatorCountry: 'us',
|
|
34
|
-
lang: 'en',
|
|
35
|
-
sdk_version: '17',
|
|
36
|
-
google_play_services_version: PLAY_SERVICES_VERSION,
|
|
37
|
-
client_sig: GMS_CLIENT_SIG,
|
|
38
|
-
callerSig: GMS_CLIENT_SIG,
|
|
39
|
-
droidguard_results: 'dummy123',
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
/** getAuthToken form: masterToken -> a short-lived bearer scoped to `service`. */
|
|
43
|
-
export function buildAuthTokenForm(masterToken, androidId, service, app, clientSig, email = PLACEHOLDER_EMAIL) {
|
|
44
|
-
return {
|
|
45
|
-
accountType: 'HOSTED_OR_GOOGLE',
|
|
46
|
-
Email: email,
|
|
47
|
-
has_permission: '1',
|
|
48
|
-
// Despite the name, this carries the master token verbatim (no encryption).
|
|
49
|
-
EncryptedPasswd: masterToken,
|
|
50
|
-
service,
|
|
51
|
-
source: 'android',
|
|
52
|
-
androidId,
|
|
53
|
-
app,
|
|
54
|
-
client_sig: clientSig,
|
|
55
|
-
device_country: 'us',
|
|
56
|
-
operatorCountry: 'us',
|
|
57
|
-
lang: 'en',
|
|
58
|
-
sdk_version: '17',
|
|
59
|
-
google_play_services_version: PLAY_SERVICES_VERSION,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/** Parses the `Key=Value` newline-delimited auth response. Values may themselves contain `=`. */
|
|
63
|
-
export function parseAuthResponse(body) {
|
|
64
|
-
const out = {};
|
|
65
|
-
for (const rawLine of body.split('\n')) {
|
|
66
|
-
const line = rawLine.trim();
|
|
67
|
-
if (line === '')
|
|
68
|
-
continue;
|
|
69
|
-
const idx = line.indexOf('=');
|
|
70
|
-
if (idx < 0)
|
|
71
|
-
continue;
|
|
72
|
-
out[line.substring(0, idx)] = line.substring(idx + 1);
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
/** `application/x-www-form-urlencoded` body. */
|
|
77
|
-
export function formEncode(form) {
|
|
78
|
-
return Object.entries(form)
|
|
79
|
-
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
80
|
-
.join('&');
|
|
81
|
-
}
|
|
82
|
-
/** The two gpsoauth exchanges. Both carry credential material in the body and never log it. */
|
|
83
|
-
export class GpsOAuth {
|
|
84
|
-
async exchangeOAuthToken(oauthToken, androidId = DEFAULT_ANDROID_ID) {
|
|
85
|
-
const parsed = await this.post(buildExchangeForm(oauthToken, androidId));
|
|
86
|
-
const token = parsed['Token'];
|
|
87
|
-
if (token == null) {
|
|
88
|
-
throw new FoyerAuthError(`google sign-in rejected the one-time token: ${parsed['Error'] ?? 'no Token in response'}`);
|
|
89
|
-
}
|
|
90
|
-
const email = parsed['Email'];
|
|
91
|
-
return { masterToken: token, email: email != null && email !== '' ? email : null };
|
|
92
|
-
}
|
|
93
|
-
async getAuthToken(masterToken, androidId, service, app, clientSig, email = PLACEHOLDER_EMAIL) {
|
|
94
|
-
const parsed = await this.post(buildAuthTokenForm(masterToken, androidId, service, app, clientSig, email));
|
|
95
|
-
const auth = parsed['Auth'];
|
|
96
|
-
if (auth == null) {
|
|
97
|
-
throw new FoyerAuthError(`google sign-in could not mint a bearer: ${parsed['Error'] ?? 'unknown'}`);
|
|
98
|
-
}
|
|
99
|
-
const expiry = parsed['Expiry'] != null ? Number.parseInt(parsed['Expiry'], 10) : NaN;
|
|
100
|
-
return { auth, expiryEpochSeconds: Number.isFinite(expiry) ? expiry : null };
|
|
101
|
-
}
|
|
102
|
-
async post(form) {
|
|
103
|
-
const resp = await fetch(AUTH_URL, {
|
|
104
|
-
method: 'POST',
|
|
105
|
-
headers: {
|
|
106
|
-
'User-Agent': 'GoogleAuth/1.4',
|
|
107
|
-
'Accept-Encoding': 'identity',
|
|
108
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
109
|
-
},
|
|
110
|
-
body: formEncode(form),
|
|
111
|
-
});
|
|
112
|
-
// A parsed reply is returned for 2xx AND for 4xx, where Google reports rejected credentials
|
|
113
|
-
// (e.g. Error=BadAuthentication); only a server-side failure (429/5xx) is retried upstream.
|
|
114
|
-
if (resp.status === 429 || resp.status >= 500) {
|
|
115
|
-
throw new FoyerAuthError(`google sign-in: HTTP ${resp.status}`);
|
|
116
|
-
}
|
|
117
|
-
return parseAuthResponse(await resp.text());
|
|
118
|
-
}
|
|
119
|
-
}
|
package/dist/googlehome/index.js
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
// Google Home over MCP: reads and controls Meni's Google Home devices and runs his routines, on the
|
|
2
|
-
// SAME agent-gateway door (`/mcp`, port 7470) and behind the same bearer/passkey/OAuth auth as the
|
|
3
|
-
// `agent_*` tools — so ChatGPT's voice mode drives them exactly as it drives the agent sessions.
|
|
4
|
-
//
|
|
5
|
-
// The device/room/routine layer is ported from coredevices/pebble-plugins `googlehome/src/server.ts`
|
|
6
|
-
// (its Kotlin `GoogleHomeMcpServer` + `DeviceResolver` origin). Lock/unlock and the passcode are
|
|
7
|
-
// deliberately ABSENT: an MCP door that unlocks the house is Meni's decision, never a default.
|
|
8
|
-
//
|
|
9
|
-
// The device list, the command codec and the sign-in all ride Google's PRIVATE app API (the foyer
|
|
10
|
-
// interface), reverse engineered and undocumented; Google can change it without notice, and no
|
|
11
|
-
// documented Google path covers non-Nest lights, plugs or speakers.
|
|
12
|
-
import { z } from "zod";
|
|
13
|
-
import { effectiveType } from "./foyer-codec.js";
|
|
14
|
-
import { FoyerAuth, FoyerClient, GoogleHomeError } from "./foyer-client.js";
|
|
15
|
-
import { FoyerAuthError, GpsOAuth } from "./gpsoauth.js";
|
|
16
|
-
import { googleHomeCredential } from "../notify.js";
|
|
17
|
-
/** Every tool's answer is JSON text, as the gateway's `agent_*` tools do. */
|
|
18
|
-
const answer = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
19
|
-
/** A refused act (a known Google Home failure: not signed in, no device matched, an API/HTTP error)
|
|
20
|
-
* is the tool's own error result carrying its reason; anything else is a bug and rethrows to the SDK,
|
|
21
|
-
* which answers a protocol error rather than a mask. */
|
|
22
|
-
function act(run) {
|
|
23
|
-
return Promise.resolve()
|
|
24
|
-
.then(run)
|
|
25
|
-
.then(answer)
|
|
26
|
-
.catch((err) => {
|
|
27
|
-
if (err instanceof GoogleHomeError || err instanceof FoyerAuthError) {
|
|
28
|
-
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
29
|
-
}
|
|
30
|
-
throw err;
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
const NOT_SIGNED_IN = "Google Home is not signed in on this machine. Run `node scripts/googlehome-signin.mjs` once in a " +
|
|
34
|
-
"terminal and sign in with the Google account that owns your home; the one-time token is exchanged " +
|
|
35
|
-
"for a long-lived master token and stored in the notify secrets file.";
|
|
36
|
-
/** The sign-in secret resolved fresh: a sign-in that just happened is seen by the next tool call, no restart. */
|
|
37
|
-
function client() {
|
|
38
|
-
const cred = googleHomeCredential();
|
|
39
|
-
if (cred == null)
|
|
40
|
-
throw new GoogleHomeError(NOT_SIGNED_IN);
|
|
41
|
-
return new FoyerClient(new FoyerAuth(cred.masterToken, cred.androidId, new GpsOAuth()));
|
|
42
|
-
}
|
|
43
|
-
function norm(s) {
|
|
44
|
-
const t = s?.trim().toLowerCase();
|
|
45
|
-
return t && t !== "" ? t : null;
|
|
46
|
-
}
|
|
47
|
-
function typeMatches(deviceType, want) {
|
|
48
|
-
const suffix = deviceType.split(".").pop()?.toLowerCase() ?? "";
|
|
49
|
-
return suffix === want || deviceType.toLowerCase() === want;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Natural requests often append a device kind to a custom Google Home name: "driveway lights" for a
|
|
53
|
-
* light-capable device actually named "driveway". Only leading articles and trailing kind words that
|
|
54
|
-
* fit the action's required capability are stripped — deliberately no general fuzzy matching, which
|
|
55
|
-
* could control the wrong device.
|
|
56
|
-
*/
|
|
57
|
-
const SELECTOR_KIND_WORDS = {
|
|
58
|
-
on_off: new Set(["light", "lights", "lamp", "lamps", "bulb", "bulbs", "switch", "switches", "outlet", "outlets", "plug", "plugs", "speaker", "speakers"]),
|
|
59
|
-
brightness: new Set(["light", "lights", "lamp", "lamps", "bulb", "bulbs"]),
|
|
60
|
-
color_temperature: new Set(["light", "lights", "lamp", "lamps", "bulb", "bulbs"]),
|
|
61
|
-
color: new Set(["light", "lights", "lamp", "lamps", "bulb", "bulbs"]),
|
|
62
|
-
volume: new Set(["speaker", "speakers"]),
|
|
63
|
-
lock: new Set(["lock", "locks"]),
|
|
64
|
-
thermostat: new Set(["thermostat", "thermostats"]),
|
|
65
|
-
media_transport: new Set(["speaker", "speakers", "player", "players", "tv", "tvs", "display", "displays"]),
|
|
66
|
-
};
|
|
67
|
-
const SELECTOR_LEADING_WORDS = new Set(["the", "my"]);
|
|
68
|
-
function selectorNameAliases(wantName, requiredCap) {
|
|
69
|
-
const original = wantName.split(/\s+/).filter((word) => word !== "");
|
|
70
|
-
if (original.length < 2)
|
|
71
|
-
return [];
|
|
72
|
-
const aliases = [];
|
|
73
|
-
const kindWords = SELECTOR_KIND_WORDS[requiredCap];
|
|
74
|
-
const suffixStripped = [...original];
|
|
75
|
-
while (suffixStripped.length > 1 && kindWords.has(suffixStripped[suffixStripped.length - 1])) {
|
|
76
|
-
suffixStripped.pop();
|
|
77
|
-
aliases.push(suffixStripped.join(" "));
|
|
78
|
-
}
|
|
79
|
-
const leadingStripped = [...original];
|
|
80
|
-
while (leadingStripped.length > 1 && SELECTOR_LEADING_WORDS.has(leadingStripped[0])) {
|
|
81
|
-
leadingStripped.shift();
|
|
82
|
-
aliases.push(leadingStripped.join(" "));
|
|
83
|
-
}
|
|
84
|
-
while (leadingStripped.length > 1 && kindWords.has(leadingStripped[leadingStripped.length - 1])) {
|
|
85
|
-
leadingStripped.pop();
|
|
86
|
-
aliases.push(leadingStripped.join(" "));
|
|
87
|
-
}
|
|
88
|
-
return [
|
|
89
|
-
...new Set(aliases.filter((alias) => {
|
|
90
|
-
const words = alias.split(/\s+/);
|
|
91
|
-
return alias !== "" && alias !== wantName && words.some((word) => !SELECTOR_LEADING_WORDS.has(word) && !kindWords.has(word));
|
|
92
|
-
})),
|
|
93
|
-
];
|
|
94
|
-
}
|
|
95
|
-
function matchExact(devices, wantedNames) {
|
|
96
|
-
for (const wanted of wantedNames) {
|
|
97
|
-
const exact = devices.filter((device) => device.name.trim().toLowerCase() === wanted);
|
|
98
|
-
if (exact.length > 0)
|
|
99
|
-
return exact;
|
|
100
|
-
}
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
|
-
function matchSubstring(devices, wantedNames) {
|
|
104
|
-
for (const wanted of wantedNames) {
|
|
105
|
-
const substring = devices.filter((device) => device.name.trim().toLowerCase().includes(wanted));
|
|
106
|
-
if (substring.length > 0)
|
|
107
|
-
return substring;
|
|
108
|
-
}
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
111
|
-
function describeSelector(sel) {
|
|
112
|
-
const parts = [
|
|
113
|
-
sel.name && sel.name.trim() !== "" ? `name="${sel.name}"` : null,
|
|
114
|
-
sel.room && sel.room.trim() !== "" ? `room="${sel.room}"` : null,
|
|
115
|
-
sel.type && sel.type.trim() !== "" ? `type="${sel.type}"` : null,
|
|
116
|
-
].filter((p) => p != null);
|
|
117
|
-
return parts.length === 0 ? "(no filters)" : parts.join(", ");
|
|
118
|
-
}
|
|
119
|
-
function nearNames(sel, devices, limit = 12) {
|
|
120
|
-
const wantRoom = norm(sel.room);
|
|
121
|
-
const wantType = norm(sel.type);
|
|
122
|
-
const wantNameTokens = (norm(sel.name) ?? "").split(/\s+/).filter((t) => t !== "");
|
|
123
|
-
const relaxed = devices.filter((d) => (wantRoom != null && d.roomName?.trim().toLowerCase().includes(wantRoom) === true) ||
|
|
124
|
-
(wantType != null && typeMatches(effectiveType(d), wantType)) ||
|
|
125
|
-
(wantNameTokens.length > 0 && wantNameTokens.some((t) => d.name.toLowerCase().includes(t))));
|
|
126
|
-
const pool = relaxed.length > 0 ? relaxed : devices;
|
|
127
|
-
return [...new Set(pool.map((d) => d.name))].slice(0, limit);
|
|
128
|
-
}
|
|
129
|
-
async function resolve(foyer, sel, requiredCap) {
|
|
130
|
-
if (norm(sel.name) == null && norm(sel.room) == null && norm(sel.type) == null) {
|
|
131
|
-
throw new GoogleHomeError('Provide at least one selector: name, room, and/or type (e.g. type="light" for all lights, ' +
|
|
132
|
-
'or room="kitchen" + type="light" for the kitchen lights).');
|
|
133
|
-
}
|
|
134
|
-
const { devices } = await foyer.getHomeGraph();
|
|
135
|
-
const wantName = norm(sel.name);
|
|
136
|
-
const wantRoom = norm(sel.room);
|
|
137
|
-
const wantType = norm(sel.type);
|
|
138
|
-
// Room matches EXACTLY; type matches the type suffix. AND-combined.
|
|
139
|
-
const scoped = devices.filter((d) => (wantRoom == null || d.roomName?.trim().toLowerCase() === wantRoom) &&
|
|
140
|
-
(wantType == null || typeMatches(effectiveType(d), wantType)));
|
|
141
|
-
let matches;
|
|
142
|
-
if (wantName == null) {
|
|
143
|
-
matches = scoped;
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
matches = matchExact(scoped, [wantName]);
|
|
147
|
-
if (matches.length === 0) {
|
|
148
|
-
const aliases = selectorNameAliases(wantName, requiredCap);
|
|
149
|
-
matches = matchExact(scoped, aliases);
|
|
150
|
-
if (matches.length === 0) {
|
|
151
|
-
const capable = scoped.filter((device) => device.capabilities.has(requiredCap));
|
|
152
|
-
matches = matchSubstring(capable, aliases);
|
|
153
|
-
}
|
|
154
|
-
if (matches.length === 0)
|
|
155
|
-
matches = matchSubstring(scoped, [wantName]);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (matches.length === 0) {
|
|
159
|
-
const near = nearNames(sel, devices);
|
|
160
|
-
const known = near.length === 0 ? "No devices are known." : `Closest known devices: ${near.map((n) => `"${n}"`).join(", ")}.`;
|
|
161
|
-
throw new GoogleHomeError(`No devices matched selector ${describeSelector(sel)}. ${known} ` +
|
|
162
|
-
"Use googlehome_list_devices to see every device with its room, type and capabilities.");
|
|
163
|
-
}
|
|
164
|
-
return matches;
|
|
165
|
-
}
|
|
166
|
-
// -------------------------------------------------------------------------------------------------
|
|
167
|
-
// JSON projection
|
|
168
|
-
// -------------------------------------------------------------------------------------------------
|
|
169
|
-
function stateToJson(s) {
|
|
170
|
-
return {
|
|
171
|
-
id: s.id,
|
|
172
|
-
online: s.online,
|
|
173
|
-
on_off: s.onOff,
|
|
174
|
-
brightness_pct: s.brightnessPct,
|
|
175
|
-
color_temperature_k: s.colorTemperatureK,
|
|
176
|
-
volume_pct: s.volumePct,
|
|
177
|
-
muted: s.muted,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
function deviceToJson(d, state) {
|
|
181
|
-
const et = effectiveType(d);
|
|
182
|
-
const out = { id: d.id, name: d.name, type: et };
|
|
183
|
-
if (d.assignedType != null && d.assignedType !== d.type)
|
|
184
|
-
out["hardware_type"] = d.type;
|
|
185
|
-
out["capabilities"] = [...d.capabilities];
|
|
186
|
-
out["room_name"] = d.roomName;
|
|
187
|
-
out["online"] = state?.online ?? null;
|
|
188
|
-
out["on_off"] = state?.onOff ?? null;
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
function resultRow(id, name, ok, state, error) {
|
|
192
|
-
const row = { id, name, ok };
|
|
193
|
-
if (state)
|
|
194
|
-
row["state"] = stateToJson(state);
|
|
195
|
-
if (error)
|
|
196
|
-
row["error"] = error;
|
|
197
|
-
return row;
|
|
198
|
-
}
|
|
199
|
-
// -------------------------------------------------------------------------------------------------
|
|
200
|
-
// Control plumbing
|
|
201
|
-
// -------------------------------------------------------------------------------------------------
|
|
202
|
-
const agent = (d) => d.agentId ?? "";
|
|
203
|
-
const partner = (d) => d.partnerDeviceId ?? "";
|
|
204
|
-
async function applyControl(args, requiredCap, capLabel, action) {
|
|
205
|
-
const foyer = client();
|
|
206
|
-
const devices = await resolve(foyer, args, requiredCap);
|
|
207
|
-
const rows = [];
|
|
208
|
-
for (const d of devices) {
|
|
209
|
-
if (!d.capabilities.has(requiredCap)) {
|
|
210
|
-
rows.push(resultRow(d.id, d.name, false, undefined, `skipped: does not support ${capLabel}`));
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
rows.push(resultRow(d.id, d.name, true, await action(foyer, d)));
|
|
215
|
-
}
|
|
216
|
-
catch (e) {
|
|
217
|
-
rows.push(resultRow(d.id, d.name, false, undefined, e instanceof Error ? e.message : String(e)));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return { matched: devices.length, results: rows };
|
|
221
|
-
}
|
|
222
|
-
const SELECTOR_KEYS = {
|
|
223
|
-
name: z.string().optional().describe('Device name to match (exact preferred, then case-insensitive substring); a trailing kind like "lights" is tolerated.'),
|
|
224
|
-
room: z.string().optional().describe('Room name to match (case-insensitive substring), e.g. "kitchen".'),
|
|
225
|
-
type: z.string().optional().describe("Device type suffix: light, speaker, outlet, switch, thermostat."),
|
|
226
|
-
};
|
|
227
|
-
function selectorFrom(a) {
|
|
228
|
-
return { name: a.name, room: a.room, type: a.type };
|
|
229
|
-
}
|
|
230
|
-
// -------------------------------------------------------------------------------------------------
|
|
231
|
-
// Tool registration
|
|
232
|
-
// -------------------------------------------------------------------------------------------------
|
|
233
|
-
/**
|
|
234
|
-
* Register the Google Home tools on the agent-gateway MCP server, beside the `agent_*` tools.
|
|
235
|
-
* Read tools enumerate and read; control tools take a selector, apply to EVERY capable match, and
|
|
236
|
-
* return per-device {id,name,ok,state|error}. There is no add/delete surface.
|
|
237
|
-
*/
|
|
238
|
-
export function registerGoogleHomeTools(server) {
|
|
239
|
-
server.tool("googlehome_list_devices", "List every Google Home device across all homes with its room, type, capabilities (on_off, brightness, color_temperature, color, volume, thermostat, media_transport) and live online/on-off state. Read-only. Use it to see the names/rooms/types the control tools' selectors target.", {}, () => act(async () => {
|
|
240
|
-
const foyer = client();
|
|
241
|
-
const { devices } = await foyer.getHomeGraph();
|
|
242
|
-
let stateById = new Map();
|
|
243
|
-
if (devices.length > 0) {
|
|
244
|
-
stateById = new Map((await foyer.getTraits(devices.map((d) => d.id))).map((s) => [s.id, s]));
|
|
245
|
-
}
|
|
246
|
-
return devices.map((d) => deviceToJson(d, stateById.get(d.id)));
|
|
247
|
-
}));
|
|
248
|
-
server.tool("googlehome_get_device_state", "Get the live state (online, on/off, brightness, color temperature, volume, mute) for one or more devices by id. Read-only.", { device_ids: z.array(z.string()).describe("Device ids to read state for") }, ({ device_ids }) => act(async () => (await client().getTraits(device_ids)).map(stateToJson)));
|
|
249
|
-
server.tool("googlehome_turn_on", 'Turn on devices. Scope with name/room/type: name="Corner Lamp" for one, type="light" for all lights, room="kitchen" + type="light" for the kitchen lights. Applies to every matching on/off device.', SELECTOR_KEYS, (a) => act(() => applyControl(selectorFrom(a), "on_off", "on/off", (f, d) => f.setOnOff(d.id, agent(d), partner(d), true))));
|
|
250
|
-
server.tool("googlehome_turn_off", 'Turn off devices. Scope with name/room/type, e.g. type="light" for all lights.', SELECTOR_KEYS, (a) => act(() => applyControl(selectorFrom(a), "on_off", "on/off", (f, d) => f.setOnOff(d.id, agent(d), partner(d), false))));
|
|
251
|
-
server.tool("googlehome_set_brightness", 'Set brightness (0-100%) on lights. Scope with name/room/type, e.g. room="bedroom" + type="light", brightness_pct=40.', { ...SELECTOR_KEYS, brightness_pct: z.number().describe("Brightness 0-100 (clamped)") }, (a) => act(() => applyControl(selectorFrom(a), "brightness", "brightness", (f, d) => f.setBrightness(d.id, agent(d), partner(d), a.brightness_pct))));
|
|
252
|
-
server.tool("googlehome_set_color_temperature", "Set white color temperature in Kelvin (warm ~2700, cool ~6500) on color-temperature lights. Scope with name/room/type, e.g. name=\"Desk Light\", kelvin=4000.", { ...SELECTOR_KEYS, kelvin: z.number().describe("Color temperature in Kelvin, e.g. 2700-6500") }, (a) => act(() => applyControl(selectorFrom(a), "color_temperature", "color temperature", (f, d) => f.setColorTemperature(d.id, agent(d), partner(d), a.kelvin))));
|
|
253
|
-
server.tool("googlehome_set_volume", 'Set volume (0-100%) on speakers. Scope with name/room/type, e.g. name="Living Room Speaker" volume_pct=30.', { ...SELECTOR_KEYS, volume_pct: z.number().describe("Volume 0-100 (clamped)") }, (a) => act(() => applyControl(selectorFrom(a), "volume", "volume", (f, d) => f.setVolume(d.id, agent(d), partner(d), a.volume_pct))));
|
|
254
|
-
server.tool("googlehome_set_muted", 'Mute or unmute speakers. Scope with name/room/type, e.g. type="speaker" muted=true.', { ...SELECTOR_KEYS, muted: z.boolean().describe("true = muted, false = unmuted") }, (a) => act(() => applyControl(selectorFrom(a), "volume", "volume/mute", (f, d) => f.setMuted(d.id, agent(d), partner(d), a.muted))));
|
|
255
|
-
server.tool("googlehome_list_routines", "List the home's Google Home routines: name and whether it can be started on demand (manually_runnable). Read-only. Use before googlehome_run_routine.", {}, () => act(async () => (await client().listAutomations()).map((r) => ({ id: r.id, name: r.name, manually_runnable: r.manuallyRunnable }))));
|
|
256
|
-
server.tool("googlehome_run_routine", 'Run a Google Home routine by name, e.g. name="Movie Night". Only manually-runnable routines can be started on demand. This runs the routine\'s EXISTING actions; it never creates or deletes anything.', { name: z.string().describe("The routine's name (case-insensitive; exact preferred)") }, ({ name }) => act(async () => {
|
|
257
|
-
const wanted = name.trim();
|
|
258
|
-
const foyer = client();
|
|
259
|
-
const routines = await foyer.listAutomations();
|
|
260
|
-
const lower = wanted.toLowerCase();
|
|
261
|
-
const match = routines.find((r) => r.name.trim().toLowerCase() === lower) ?? routines.find((r) => r.name.trim().toLowerCase().includes(lower));
|
|
262
|
-
if (match == null) {
|
|
263
|
-
throw new GoogleHomeError(`No routine named '${wanted}'. Available: ${routines.map((r) => `"${r.name}"`).join(", ")}`);
|
|
264
|
-
}
|
|
265
|
-
return { name: match.name, id: match.id, ok: await foyer.runAutomation(match) };
|
|
266
|
-
}));
|
|
267
|
-
}
|