@camstack/addon-export-google 0.1.2 → 0.1.3
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/export-google.addon.js +425 -65
- package/dist/export-google.addon.mjs +425 -65
- package/package.json +1 -1
|
@@ -11379,6 +11379,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
11379
11379
|
content: string()
|
|
11380
11380
|
})) }), { auth: "admin" });
|
|
11381
11381
|
/**
|
|
11382
|
+
* Identity — preserves literal types for downstream inference.
|
|
11383
|
+
*
|
|
11384
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
11385
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
11386
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
11387
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
11388
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
11389
|
+
*/
|
|
11390
|
+
function defineCustomActions(spec) {
|
|
11391
|
+
return spec;
|
|
11392
|
+
}
|
|
11393
|
+
function customAction(input, output, options) {
|
|
11394
|
+
return {
|
|
11395
|
+
input,
|
|
11396
|
+
output,
|
|
11397
|
+
kind: options?.kind ?? "query",
|
|
11398
|
+
auth: options?.auth ?? "protected",
|
|
11399
|
+
scope: options?.scope ?? { kind: "system" },
|
|
11400
|
+
...options?.caller ? { caller: "required" } : {}
|
|
11401
|
+
};
|
|
11402
|
+
}
|
|
11403
|
+
/**
|
|
11382
11404
|
* `custom-model-registry` — collection cap exposing operator-registered
|
|
11383
11405
|
* custom detection models. Each provider (today: `addon-model-studio`)
|
|
11384
11406
|
* contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
|
|
@@ -36194,6 +36216,32 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
|
36194
36216
|
var MB = 1024 * 1024;
|
|
36195
36217
|
1024 * MB, 3072 * MB;
|
|
36196
36218
|
//#endregion
|
|
36219
|
+
//#region src/custom-actions.ts
|
|
36220
|
+
/**
|
|
36221
|
+
* Google Home export — customActions catalog.
|
|
36222
|
+
*
|
|
36223
|
+
* One entry. The settings form's `type: 'button'` field dispatches through the
|
|
36224
|
+
* generic `api.addons.custom.mutate({ addonId, action, input })` channel, so a
|
|
36225
|
+
* button here costs no capability, no codegen and no framework publish train —
|
|
36226
|
+
* the catalog travels inside this addon's own bundle.
|
|
36227
|
+
*/
|
|
36228
|
+
var exportGoogleActions = defineCustomActions({
|
|
36229
|
+
/**
|
|
36230
|
+
* Re-scan the connected `network-access` providers and refresh the address
|
|
36231
|
+
* shown under the field.
|
|
36232
|
+
*
|
|
36233
|
+
* Deliberately NOT the same contract as `export-alexa`'s action of the same
|
|
36234
|
+
* shape: that one replaces a manual override when it is no longer detected,
|
|
36235
|
+
* because Alexa ROUTES on the value. Nothing routes on this one — it is what
|
|
36236
|
+
* the operator already pasted into Google's console — so this only fills the
|
|
36237
|
+
* field when it is empty. `filled` says whether it did.
|
|
36238
|
+
*/
|
|
36239
|
+
detectPublicHubUrl: customAction(object({}).optional(), object({
|
|
36240
|
+
publicHubUrl: string(),
|
|
36241
|
+
detectedPublicHubUrl: string(),
|
|
36242
|
+
filled: boolean()
|
|
36243
|
+
}), { kind: "mutation" }) });
|
|
36244
|
+
//#endregion
|
|
36197
36245
|
//#region src/google-home/trait-catalog.ts
|
|
36198
36246
|
/**
|
|
36199
36247
|
* The trait catalog — one ROW per camstack capability, and that row is the
|
|
@@ -36222,7 +36270,7 @@ var MB = 1024 * 1024;
|
|
|
36222
36270
|
* Cameras. Google's `CameraStream` trait answers `GetCameraStream` with a
|
|
36223
36271
|
* playable URL or a WebRTC signaling endpoint, and neither exists here yet, so
|
|
36224
36272
|
* a camera would be declared and then fail every command. It is omitted rather
|
|
36225
|
-
* than declared — see docs/decisions/adr-
|
|
36273
|
+
* than declared — see docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
|
|
36226
36274
|
*/
|
|
36227
36275
|
/**
|
|
36228
36276
|
* The Google state a SUCCEEDED call establishes.
|
|
@@ -36971,9 +37019,262 @@ function buildGoogleOauthIntegration() {
|
|
|
36971
37019
|
};
|
|
36972
37020
|
}
|
|
36973
37021
|
//#endregion
|
|
37022
|
+
//#region src/public-hub-url.ts
|
|
37023
|
+
/**
|
|
37024
|
+
* The hub's public HTTPS origin — detected, never invented.
|
|
37025
|
+
*
|
|
37026
|
+
* `publicHubUrl` is the single operator-facing value in this addon, and the
|
|
37027
|
+
* three URLs pasted into the Google Home Developer Console (fulfillment,
|
|
37028
|
+
* authorization, token) are all rendered from it. Nothing ROUTES on it: Google
|
|
37029
|
+
* calls the fulfillment URL it was configured with. A wrong value therefore
|
|
37030
|
+
* fails at paste time, in the console, hours before anything here notices —
|
|
37031
|
+
* which is exactly why the field has to say where its content came from.
|
|
37032
|
+
*
|
|
37033
|
+
* ## Where the URL comes from
|
|
37034
|
+
*
|
|
37035
|
+
* The `network-access` capability, same source `addon-export-alexa` uses. It is
|
|
37036
|
+
* a codegen'd collection cap with `scope: 'system'`, so it is reachable
|
|
37037
|
+
* cross-process through `ctx.api` — unlike the `addons` core router, which a
|
|
37038
|
+
* forked addon calling `ctx.api.addons.*` would wait on forever.
|
|
37039
|
+
*
|
|
37040
|
+
* **Not** `CAMSTACK_HUB_PUBLIC_URL`. That variable answers a different
|
|
37041
|
+
* question: which origin the hub should mint its OWN media / model-distribution
|
|
37042
|
+
* links on, and it defaults to `https://127.0.0.1:4443`. It is routinely a
|
|
37043
|
+
* loopback or LAN address, and the backend's `publicHubUrl()` fallback is
|
|
37044
|
+
* localhost in dev. Google calls from its own cloud, so a LAN answer here is
|
|
37045
|
+
* worse than no answer — it produces three console URLs that look complete and
|
|
37046
|
+
* can never be reached.
|
|
37047
|
+
*
|
|
37048
|
+
* ## Why this is a second copy of Alexa's mechanism
|
|
37049
|
+
*
|
|
37050
|
+
* Alexa's lives in `packages/addon-export-alexa/src/export-alexa.addon.ts` as
|
|
37051
|
+
* private methods, and addons never import each other. The only shared home
|
|
37052
|
+
* would be `@camstack/types` or `@camstack/system` — both stay host-resolved in
|
|
37053
|
+
* an addon bundle (`tools/build/vite-lib.preset.ts`), so putting it there would
|
|
37054
|
+
* put this addon on the framework publish train and destroy the one property
|
|
37055
|
+
* that makes it attractive: `camstack deploy packages/addon-export-google` and
|
|
37056
|
+
* nothing else. The SOURCE and the POLICY are Alexa's; two behaviours diverge
|
|
37057
|
+
* on purpose and both are named below.
|
|
37058
|
+
*
|
|
37059
|
+
* ## Two deliberate divergences from Alexa
|
|
37060
|
+
*
|
|
37061
|
+
* 1. **Refresh never clobbers.** Alexa's `redetectPublicHubUrl` replaces a
|
|
37062
|
+
* manual override whenever it is not among the detected endpoints — sound
|
|
37063
|
+
* there, because the URL is baked into every Alexa-bound JWT as a routing
|
|
37064
|
+
* claim and a stale one breaks routing. Here the value is what the operator
|
|
37065
|
+
* already pasted into Google's console; silently rewriting it would leave
|
|
37066
|
+
* the console and the hub disagreeing with no visible cause. Detection fills
|
|
37067
|
+
* an EMPTY field and does nothing else.
|
|
37068
|
+
* 2. **A non-routable origin is dropped.** Alexa forwards whatever the cap
|
|
37069
|
+
* reports. See {@link isPubliclyRoutableOrigin}.
|
|
37070
|
+
*/
|
|
37071
|
+
/** Trailing slashes off, surrounding space off. Nothing else is rewritten. */
|
|
37072
|
+
var normalisePublicHubUrl = (raw) => raw.trim().replace(/\/+$/, "");
|
|
37073
|
+
var LOOPBACK_HOSTS = [
|
|
37074
|
+
"localhost",
|
|
37075
|
+
"127.0.0.1",
|
|
37076
|
+
"::1",
|
|
37077
|
+
"[::1]",
|
|
37078
|
+
"0.0.0.0"
|
|
37079
|
+
];
|
|
37080
|
+
/**
|
|
37081
|
+
* Whether Google's cloud could plausibly reach this origin.
|
|
37082
|
+
*
|
|
37083
|
+
* The `network-access` providers this hub ships (Cloudflare Tunnel, Tailscale
|
|
37084
|
+
* Funnel) publish public FQDNs, but the cap does not promise it: a provider is
|
|
37085
|
+
* free to report an HTTPS ingress on a private address. Defaulting the field to
|
|
37086
|
+
* one of those would hand the operator three console URLs that look finished
|
|
37087
|
+
* and can never answer — strictly worse than an empty field, because an empty
|
|
37088
|
+
* field is visibly unfinished.
|
|
37089
|
+
*
|
|
37090
|
+
* Conservative on purpose: only loopback, RFC1918, link-local, `.local` and
|
|
37091
|
+
* dotless hostnames are refused. A public tunnel hostname always has a dot.
|
|
37092
|
+
*/
|
|
37093
|
+
var isPubliclyRoutableOrigin = (origin) => {
|
|
37094
|
+
let host;
|
|
37095
|
+
try {
|
|
37096
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
37097
|
+
} catch {
|
|
37098
|
+
return false;
|
|
37099
|
+
}
|
|
37100
|
+
if (host.length === 0) return false;
|
|
37101
|
+
if (LOOPBACK_HOSTS.includes(host)) return false;
|
|
37102
|
+
if (host.startsWith("127.") || host.startsWith("169.254.")) return false;
|
|
37103
|
+
if (host.startsWith("10.") || host.startsWith("192.168.")) return false;
|
|
37104
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
|
|
37105
|
+
if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".lan")) return false;
|
|
37106
|
+
if (!host.includes(".")) return false;
|
|
37107
|
+
return true;
|
|
37108
|
+
};
|
|
37109
|
+
/**
|
|
37110
|
+
* Every publicly routable HTTPS origin the `network-access` cap reports, in the
|
|
37111
|
+
* cap's own order, deduped. Empty when there is none — the caller leaves the
|
|
37112
|
+
* field alone rather than inventing a value.
|
|
37113
|
+
*/
|
|
37114
|
+
var detectPublicHubOrigins = async (deps) => {
|
|
37115
|
+
const fromList = await originsFromListEndpoints(deps);
|
|
37116
|
+
if (fromList.length > 0) return fromList;
|
|
37117
|
+
const fromStatus = await originFromGetStatus(deps);
|
|
37118
|
+
if (fromStatus.length > 0) return [fromStatus];
|
|
37119
|
+
deps.logger.warn("export-google: no connected HTTPS external address — the public hub URL cannot be derived, so the Google console URLs stay unrendered. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …) and press \"Detect external address\", or type the URL on the settings form.");
|
|
37120
|
+
return [];
|
|
37121
|
+
};
|
|
37122
|
+
var usableOrigins = (candidates, logger) => {
|
|
37123
|
+
const out = /* @__PURE__ */ new Set();
|
|
37124
|
+
for (const candidate of candidates) {
|
|
37125
|
+
if (candidate.protocol !== "https" || candidate.url.length === 0) continue;
|
|
37126
|
+
const origin = normalisePublicHubUrl(candidate.url);
|
|
37127
|
+
if (!isPubliclyRoutableOrigin(origin)) {
|
|
37128
|
+
logger.info("export-google: ignored an external address that is not reachable from the internet — Google calls the fulfillment URL from its own cloud", { meta: { origin } });
|
|
37129
|
+
continue;
|
|
37130
|
+
}
|
|
37131
|
+
out.add(origin);
|
|
37132
|
+
}
|
|
37133
|
+
return [...out];
|
|
37134
|
+
};
|
|
37135
|
+
var originsFromListEndpoints = async (deps) => {
|
|
37136
|
+
try {
|
|
37137
|
+
return usableOrigins(await deps.network.listEndpoints(), deps.logger);
|
|
37138
|
+
} catch (err) {
|
|
37139
|
+
deps.logger.debug("export-google: networkAccess.listEndpoints failed", { meta: { error: errMsg(err) } });
|
|
37140
|
+
return [];
|
|
37141
|
+
}
|
|
37142
|
+
};
|
|
37143
|
+
var originFromGetStatus = async (deps) => {
|
|
37144
|
+
try {
|
|
37145
|
+
const status = await deps.network.getStatus();
|
|
37146
|
+
if (status === null || !status.connected || status.endpoint === null) return "";
|
|
37147
|
+
return usableOrigins([status.endpoint], deps.logger)[0] ?? "";
|
|
37148
|
+
} catch (err) {
|
|
37149
|
+
deps.logger.debug("export-google: networkAccess.getStatus failed", { meta: { error: errMsg(err) } });
|
|
37150
|
+
return "";
|
|
37151
|
+
}
|
|
37152
|
+
};
|
|
37153
|
+
/**
|
|
37154
|
+
* Apply the default-not-override policy.
|
|
37155
|
+
*
|
|
37156
|
+
* The operator's value is never in the returned patch. Detection's own answer
|
|
37157
|
+
* always is, because the settings panel needs it to say "you set this one, and
|
|
37158
|
+
* a different one is detected".
|
|
37159
|
+
*/
|
|
37160
|
+
var resolvePublicHubUrl = (current, detected) => {
|
|
37161
|
+
const detectedPublicHubUrl = detected[0] ?? "";
|
|
37162
|
+
if (current.length > 0) return {
|
|
37163
|
+
patch: { detectedPublicHubUrl },
|
|
37164
|
+
publicHubUrl: current,
|
|
37165
|
+
detectedPublicHubUrl,
|
|
37166
|
+
defaulted: false
|
|
37167
|
+
};
|
|
37168
|
+
if (detectedPublicHubUrl.length === 0) return {
|
|
37169
|
+
patch: { detectedPublicHubUrl },
|
|
37170
|
+
publicHubUrl: "",
|
|
37171
|
+
detectedPublicHubUrl,
|
|
37172
|
+
defaulted: false
|
|
37173
|
+
};
|
|
37174
|
+
return {
|
|
37175
|
+
patch: {
|
|
37176
|
+
publicHubUrl: detectedPublicHubUrl,
|
|
37177
|
+
detectedPublicHubUrl
|
|
37178
|
+
},
|
|
37179
|
+
publicHubUrl: detectedPublicHubUrl,
|
|
37180
|
+
detectedPublicHubUrl,
|
|
37181
|
+
defaulted: true
|
|
37182
|
+
};
|
|
37183
|
+
};
|
|
37184
|
+
/** Which of the two authored the value currently in the field. */
|
|
37185
|
+
var publicHubUrlSource = (state) => {
|
|
37186
|
+
if (state.publicHubUrl.length === 0) return "unset";
|
|
37187
|
+
if (state.detectedPublicHubUrl.length > 0 && state.publicHubUrl === state.detectedPublicHubUrl) return "derived";
|
|
37188
|
+
return "operator-set";
|
|
37189
|
+
};
|
|
37190
|
+
/**
|
|
37191
|
+
* The line under the field. A URL that appeared by itself with no provenance is
|
|
37192
|
+
* worse than a blank field: the operator cannot tell whether it is right.
|
|
37193
|
+
*/
|
|
37194
|
+
var buildPublicHubUrlNotice = (state) => {
|
|
37195
|
+
switch (publicHubUrlSource(state)) {
|
|
37196
|
+
case "derived": return {
|
|
37197
|
+
variant: "info",
|
|
37198
|
+
content: `Derived from remote access — ${state.publicHubUrl} is the first HTTPS address the network-access capability reports. Edit the field to override it; nothing here ever overwrites a value you set.`
|
|
37199
|
+
};
|
|
37200
|
+
case "operator-set": return {
|
|
37201
|
+
variant: "info",
|
|
37202
|
+
content: state.detectedPublicHubUrl.length > 0 ? `Set by you — ${state.publicHubUrl}. The address currently detected from remote access is ${state.detectedPublicHubUrl}; paste it in yourself if you want to switch, detection will not do it for you.` : `Set by you — ${state.publicHubUrl}. No external HTTPS address is detected right now, so nothing is corroborating it.`
|
|
37203
|
+
};
|
|
37204
|
+
case "unset": return state.detectedPublicHubUrl.length > 0 ? {
|
|
37205
|
+
variant: "info",
|
|
37206
|
+
content: `Detected external address: ${state.detectedPublicHubUrl}. Press "Detect external address" to fill the field with it.`
|
|
37207
|
+
} : {
|
|
37208
|
+
variant: "warning",
|
|
37209
|
+
content: "No public HTTPS address detected — no network-access provider on this hub reports a connected HTTPS endpoint, so there is nothing to derive from. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …) and press \"Detect external address\", or type the URL yourself. An address on your own network will not do: Google calls this URL from its own cloud."
|
|
37210
|
+
};
|
|
37211
|
+
}
|
|
37212
|
+
};
|
|
37213
|
+
/**
|
|
37214
|
+
* The `device-export` setup block: the three console URLs plus the
|
|
37215
|
+
* linked-account count. All three come off the SAME origin, so they can never
|
|
37216
|
+
* disagree with each other.
|
|
37217
|
+
*/
|
|
37218
|
+
var buildConsoleSetup = (input) => {
|
|
37219
|
+
const origin = normalisePublicHubUrl(input.publicHubUrl);
|
|
37220
|
+
const linked = String(input.linkedAccounts);
|
|
37221
|
+
if (origin.length === 0) return {
|
|
37222
|
+
note: ["Set the public hub URL on the settings form first — every URL Google needs is derived from it.", buildPublicHubUrlNotice(input).content].join("\n"),
|
|
37223
|
+
fields: [{
|
|
37224
|
+
label: "Linked Google accounts",
|
|
37225
|
+
value: linked
|
|
37226
|
+
}]
|
|
37227
|
+
};
|
|
37228
|
+
return {
|
|
37229
|
+
note: [
|
|
37230
|
+
"In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
|
|
37231
|
+
"The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
|
|
37232
|
+
"This hub pushes nothing to Google — state is read by polling, so a change made outside the Home app appears on the next query, not instantly.",
|
|
37233
|
+
buildPublicHubUrlNotice(input).content
|
|
37234
|
+
].join("\n"),
|
|
37235
|
+
fields: [
|
|
37236
|
+
{
|
|
37237
|
+
label: "Fulfillment URL",
|
|
37238
|
+
value: `${origin}/addon/${input.addonId}/fulfillment`
|
|
37239
|
+
},
|
|
37240
|
+
{
|
|
37241
|
+
label: "Authorization URL",
|
|
37242
|
+
value: `${origin}/api/oauth2/authorize?integration=${input.addonId}`
|
|
37243
|
+
},
|
|
37244
|
+
{
|
|
37245
|
+
label: "Token URL",
|
|
37246
|
+
value: `${origin}/api/oauth2/token`
|
|
37247
|
+
},
|
|
37248
|
+
{
|
|
37249
|
+
label: "Linked Google accounts",
|
|
37250
|
+
value: linked
|
|
37251
|
+
}
|
|
37252
|
+
]
|
|
37253
|
+
};
|
|
37254
|
+
};
|
|
37255
|
+
/**
|
|
37256
|
+
* Detect, apply the policy, persist, and say out loud what happened.
|
|
37257
|
+
*
|
|
37258
|
+
* Every branch here logs: a fill names the URL it picked and the source it came
|
|
37259
|
+
* from, and a miss that leaves the field empty says so rather than passing for
|
|
37260
|
+
* "never ran".
|
|
37261
|
+
*/
|
|
37262
|
+
var refreshPublicHubUrl = async (deps) => {
|
|
37263
|
+
const detected = await deps.detect();
|
|
37264
|
+
const resolution = resolvePublicHubUrl(deps.current().publicHubUrl, detected);
|
|
37265
|
+
await deps.persist(resolution.patch);
|
|
37266
|
+
if (resolution.defaulted) deps.logger.info("export-google: defaulted public hub URL to the detected external address", { meta: {
|
|
37267
|
+
publicHubUrl: resolution.publicHubUrl,
|
|
37268
|
+
source: "network-access"
|
|
37269
|
+
} });
|
|
37270
|
+
else if (resolution.publicHubUrl.length === 0) deps.logger.warn("export-google: public hub URL still unset after detection — the Google console URLs cannot be rendered until one is available", { meta: { detectedCount: detected.length } });
|
|
37271
|
+
return resolution;
|
|
37272
|
+
};
|
|
37273
|
+
//#endregion
|
|
36974
37274
|
//#region src/types.ts
|
|
36975
37275
|
var DEFAULT_SETTINGS = {
|
|
36976
37276
|
publicHubUrl: "",
|
|
37277
|
+
detectedPublicHubUrl: "",
|
|
36977
37278
|
exposed: [],
|
|
36978
37279
|
linkedAccounts: []
|
|
36979
37280
|
};
|
|
@@ -37012,7 +37313,7 @@ var DEFAULT_SETTINGS = {
|
|
|
37012
37313
|
*
|
|
37013
37314
|
* Switches, dimmable lights, locks and covers — the non-camera fleet neither
|
|
37014
37315
|
* `export-hap` nor `export-alexa` covers. Cameras are out of scope on purpose
|
|
37015
|
-
* (docs/decisions/adr-
|
|
37316
|
+
* (docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md); the trait catalog is the single place that
|
|
37016
37317
|
* decides, so widening scope is one row.
|
|
37017
37318
|
*/
|
|
37018
37319
|
var ADDON_ID = "export-google";
|
|
@@ -37027,6 +37328,9 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37027
37328
|
logger: this.ctx.logger
|
|
37028
37329
|
});
|
|
37029
37330
|
this.gateway = gateway;
|
|
37331
|
+
this.detectPublicHubUrl().catch((err) => {
|
|
37332
|
+
this.ctx.logger.warn("export-google: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
37333
|
+
});
|
|
37030
37334
|
const deviceExportProvider = {
|
|
37031
37335
|
getStatus: async () => ({
|
|
37032
37336
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -37066,26 +37370,93 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37066
37370
|
}
|
|
37067
37371
|
}));
|
|
37068
37372
|
const oauthIntegrationProvider = { getDescriptor: async () => buildGoogleOauthIntegration() };
|
|
37373
|
+
const offTunnelStarted = this.ctx.eventBus.subscribe({ category: EventCategory.NetworkTunnelStarted }, () => {
|
|
37374
|
+
this.detectPublicHubUrl().catch((err) => {
|
|
37375
|
+
this.ctx.logger.warn("export-google: public hub URL detect after tunnel connect failed", { meta: { error: errMsg(err) } });
|
|
37376
|
+
});
|
|
37377
|
+
});
|
|
37378
|
+
this.ctx.addDisposer(async () => offTunnelStarted());
|
|
37069
37379
|
this.ctx.logger.info("export-google: initialized", { meta: {
|
|
37070
37380
|
exposedCount: this.config.exposed.length,
|
|
37071
37381
|
linkedAccounts: this.config.linkedAccounts.length,
|
|
37072
37382
|
publicHubUrlSet: this.config.publicHubUrl.length > 0,
|
|
37073
37383
|
willReportState: false
|
|
37074
37384
|
} });
|
|
37075
|
-
return {
|
|
37076
|
-
|
|
37077
|
-
|
|
37078
|
-
|
|
37079
|
-
|
|
37080
|
-
|
|
37081
|
-
|
|
37082
|
-
|
|
37385
|
+
return {
|
|
37386
|
+
providers: [
|
|
37387
|
+
{
|
|
37388
|
+
capability: deviceExportCapability,
|
|
37389
|
+
provider: deviceExportProvider
|
|
37390
|
+
},
|
|
37391
|
+
{
|
|
37392
|
+
capability: addonRoutesCapability,
|
|
37393
|
+
provider: routeProvider
|
|
37394
|
+
},
|
|
37395
|
+
{
|
|
37396
|
+
capability: oauthIntegrationCapability,
|
|
37397
|
+
provider: oauthIntegrationProvider
|
|
37398
|
+
}
|
|
37399
|
+
],
|
|
37400
|
+
customActions: exportGoogleActions,
|
|
37401
|
+
actionHandlers: { detectPublicHubUrl: async () => this.detectPublicHubUrl() }
|
|
37402
|
+
};
|
|
37403
|
+
}
|
|
37404
|
+
/** This addon's slice of `PublicHubUrlState`. */
|
|
37405
|
+
publicHubUrlState() {
|
|
37406
|
+
return {
|
|
37407
|
+
publicHubUrl: this.config.publicHubUrl,
|
|
37408
|
+
detectedPublicHubUrl: this.config.detectedPublicHubUrl
|
|
37409
|
+
};
|
|
37410
|
+
}
|
|
37411
|
+
/**
|
|
37412
|
+
* Adapter from the codegen'd `network-access` router onto the narrow reader
|
|
37413
|
+
* `public-hub-url.ts` consumes. Fields are copied one by one rather than
|
|
37414
|
+
* passed through, so the module never depends on the cap's wider shape and a
|
|
37415
|
+
* test double for it needs no cast.
|
|
37416
|
+
*/
|
|
37417
|
+
networkAccessReader() {
|
|
37418
|
+
return {
|
|
37419
|
+
listEndpoints: async () => {
|
|
37420
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query({})).map((entry) => ({
|
|
37421
|
+
url: entry.url,
|
|
37422
|
+
protocol: entry.protocol
|
|
37423
|
+
}));
|
|
37083
37424
|
},
|
|
37084
|
-
{
|
|
37085
|
-
|
|
37086
|
-
|
|
37425
|
+
getStatus: async () => {
|
|
37426
|
+
const status = await this.ctx.api.networkAccess.getStatus.query({});
|
|
37427
|
+
return {
|
|
37428
|
+
connected: status.connected,
|
|
37429
|
+
endpoint: status.endpoint === null ? null : {
|
|
37430
|
+
url: status.endpoint.url,
|
|
37431
|
+
protocol: status.endpoint.protocol
|
|
37432
|
+
}
|
|
37433
|
+
};
|
|
37087
37434
|
}
|
|
37088
|
-
|
|
37435
|
+
};
|
|
37436
|
+
}
|
|
37437
|
+
/**
|
|
37438
|
+
* Refresh the detected address and DEFAULT the field to it when it is empty.
|
|
37439
|
+
* Backs both the boot-time detect and the "Detect external address" button.
|
|
37440
|
+
*
|
|
37441
|
+
* Never replaces a value the operator set — that value is what they already
|
|
37442
|
+
* pasted into the Google console, and rewriting it here would leave the two
|
|
37443
|
+
* sides disagreeing with nothing on screen to say so.
|
|
37444
|
+
*/
|
|
37445
|
+
async detectPublicHubUrl() {
|
|
37446
|
+
const resolution = await refreshPublicHubUrl({
|
|
37447
|
+
current: () => this.publicHubUrlState(),
|
|
37448
|
+
detect: () => detectPublicHubOrigins({
|
|
37449
|
+
network: this.networkAccessReader(),
|
|
37450
|
+
logger: this.ctx.logger
|
|
37451
|
+
}),
|
|
37452
|
+
persist: (patch) => this.updateGlobalSettings(patch),
|
|
37453
|
+
logger: this.ctx.logger
|
|
37454
|
+
});
|
|
37455
|
+
return {
|
|
37456
|
+
publicHubUrl: resolution.publicHubUrl,
|
|
37457
|
+
detectedPublicHubUrl: resolution.detectedPublicHubUrl,
|
|
37458
|
+
filled: resolution.defaulted
|
|
37459
|
+
};
|
|
37089
37460
|
}
|
|
37090
37461
|
async onShutdown() {
|
|
37091
37462
|
this.gateway = null;
|
|
@@ -37155,65 +37526,54 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37155
37526
|
* tool for a real credential anyway (docs/decisions/adr-0269-a-google-export-that-holds-no-google-credential.md).
|
|
37156
37527
|
*/
|
|
37157
37528
|
buildSetupBlock() {
|
|
37158
|
-
|
|
37159
|
-
|
|
37160
|
-
|
|
37161
|
-
|
|
37162
|
-
|
|
37163
|
-
|
|
37164
|
-
|
|
37165
|
-
|
|
37166
|
-
|
|
37167
|
-
|
|
37168
|
-
|
|
37169
|
-
|
|
37170
|
-
"The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
|
|
37171
|
-
"This hub pushes nothing to Google — state is read by polling, so a change made outside the Home app appears on the next query, not instantly."
|
|
37172
|
-
].join("\n"),
|
|
37529
|
+
return buildConsoleSetup({
|
|
37530
|
+
addonId: ADDON_ID,
|
|
37531
|
+
...this.publicHubUrlState(),
|
|
37532
|
+
linkedAccounts: this.config.linkedAccounts.length
|
|
37533
|
+
});
|
|
37534
|
+
}
|
|
37535
|
+
globalSettingsSchema() {
|
|
37536
|
+
return this.schema({ sections: [{
|
|
37537
|
+
id: ADDON_ID,
|
|
37538
|
+
title: "Google Home export",
|
|
37539
|
+
description: "Publishes switches, dimmable lights, locks and covers to Google Home. The hub answers Google directly — no cloud function, and no Google credential is stored here.",
|
|
37540
|
+
columns: 1,
|
|
37173
37541
|
fields: [
|
|
37174
37542
|
{
|
|
37175
|
-
|
|
37176
|
-
|
|
37177
|
-
|
|
37178
|
-
|
|
37179
|
-
|
|
37180
|
-
|
|
37543
|
+
type: "info",
|
|
37544
|
+
key: "__google-setup-banner",
|
|
37545
|
+
label: "Setup overview",
|
|
37546
|
+
variant: "info",
|
|
37547
|
+
content: [
|
|
37548
|
+
"This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
|
|
37549
|
+
"Create a cloud-to-cloud integration in the Google Home Developer Console, paste the three URLs from the Export panel, and link your account from the Home app.",
|
|
37550
|
+
"Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
|
|
37551
|
+
].join("\n")
|
|
37181
37552
|
},
|
|
37553
|
+
this.field({
|
|
37554
|
+
type: "text",
|
|
37555
|
+
key: "publicHubUrl",
|
|
37556
|
+
label: "Public hub URL",
|
|
37557
|
+
description: "HTTPS origin Google reaches this hub on, e.g. https://hub.example.com. Used to render the URLs you paste into the Google console. Left empty, it defaults to the first external address remote access reports; once it holds a value, nothing overwrites it.",
|
|
37558
|
+
placeholder: "https://hub.example.com",
|
|
37559
|
+
default: DEFAULT_SETTINGS.publicHubUrl
|
|
37560
|
+
}),
|
|
37182
37561
|
{
|
|
37183
|
-
|
|
37184
|
-
|
|
37562
|
+
type: "info",
|
|
37563
|
+
key: "__public-hub-url-source",
|
|
37564
|
+
label: "Where this came from",
|
|
37565
|
+
...buildPublicHubUrlNotice(this.publicHubUrlState())
|
|
37185
37566
|
},
|
|
37186
37567
|
{
|
|
37187
|
-
|
|
37188
|
-
|
|
37568
|
+
type: "button",
|
|
37569
|
+
key: "__detect-public-hub-url",
|
|
37570
|
+
label: "External address",
|
|
37571
|
+
description: "Re-scan the connected remote-access providers. Fills the field above when it is empty; a value you set is left exactly as it is.",
|
|
37572
|
+
buttonLabel: "Detect external address",
|
|
37573
|
+
action: "detectPublicHubUrl",
|
|
37574
|
+
variant: "default"
|
|
37189
37575
|
}
|
|
37190
37576
|
]
|
|
37191
|
-
};
|
|
37192
|
-
}
|
|
37193
|
-
globalSettingsSchema() {
|
|
37194
|
-
return this.schema({ sections: [{
|
|
37195
|
-
id: ADDON_ID,
|
|
37196
|
-
title: "Google Home export",
|
|
37197
|
-
description: "Publishes switches, dimmable lights, locks and covers to Google Home. The hub answers Google directly — no cloud function, and no Google credential is stored here.",
|
|
37198
|
-
columns: 1,
|
|
37199
|
-
fields: [{
|
|
37200
|
-
type: "info",
|
|
37201
|
-
key: "__google-setup-banner",
|
|
37202
|
-
label: "Setup overview",
|
|
37203
|
-
variant: "info",
|
|
37204
|
-
content: [
|
|
37205
|
-
"This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
|
|
37206
|
-
"Create a cloud-to-cloud integration in the Google Home Developer Console, paste the three URLs from the Export panel, and link your account from the Home app.",
|
|
37207
|
-
"Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
|
|
37208
|
-
].join("\n")
|
|
37209
|
-
}, this.field({
|
|
37210
|
-
type: "text",
|
|
37211
|
-
key: "publicHubUrl",
|
|
37212
|
-
label: "Public hub URL",
|
|
37213
|
-
description: "HTTPS origin Google reaches this hub on, e.g. https://hub.example.com. Used to render the URLs you paste into the Google console.",
|
|
37214
|
-
placeholder: "https://hub.example.com",
|
|
37215
|
-
default: DEFAULT_SETTINGS.publicHubUrl
|
|
37216
|
-
})]
|
|
37217
37577
|
}] });
|
|
37218
37578
|
}
|
|
37219
37579
|
/**
|
|
@@ -11375,6 +11375,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
11375
11375
|
content: string()
|
|
11376
11376
|
})) }), { auth: "admin" });
|
|
11377
11377
|
/**
|
|
11378
|
+
* Identity — preserves literal types for downstream inference.
|
|
11379
|
+
*
|
|
11380
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
11381
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
11382
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
11383
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
11384
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
11385
|
+
*/
|
|
11386
|
+
function defineCustomActions(spec) {
|
|
11387
|
+
return spec;
|
|
11388
|
+
}
|
|
11389
|
+
function customAction(input, output, options) {
|
|
11390
|
+
return {
|
|
11391
|
+
input,
|
|
11392
|
+
output,
|
|
11393
|
+
kind: options?.kind ?? "query",
|
|
11394
|
+
auth: options?.auth ?? "protected",
|
|
11395
|
+
scope: options?.scope ?? { kind: "system" },
|
|
11396
|
+
...options?.caller ? { caller: "required" } : {}
|
|
11397
|
+
};
|
|
11398
|
+
}
|
|
11399
|
+
/**
|
|
11378
11400
|
* `custom-model-registry` — collection cap exposing operator-registered
|
|
11379
11401
|
* custom detection models. Each provider (today: `addon-model-studio`)
|
|
11380
11402
|
* contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
|
|
@@ -36190,6 +36212,32 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
|
36190
36212
|
var MB = 1024 * 1024;
|
|
36191
36213
|
1024 * MB, 3072 * MB;
|
|
36192
36214
|
//#endregion
|
|
36215
|
+
//#region src/custom-actions.ts
|
|
36216
|
+
/**
|
|
36217
|
+
* Google Home export — customActions catalog.
|
|
36218
|
+
*
|
|
36219
|
+
* One entry. The settings form's `type: 'button'` field dispatches through the
|
|
36220
|
+
* generic `api.addons.custom.mutate({ addonId, action, input })` channel, so a
|
|
36221
|
+
* button here costs no capability, no codegen and no framework publish train —
|
|
36222
|
+
* the catalog travels inside this addon's own bundle.
|
|
36223
|
+
*/
|
|
36224
|
+
var exportGoogleActions = defineCustomActions({
|
|
36225
|
+
/**
|
|
36226
|
+
* Re-scan the connected `network-access` providers and refresh the address
|
|
36227
|
+
* shown under the field.
|
|
36228
|
+
*
|
|
36229
|
+
* Deliberately NOT the same contract as `export-alexa`'s action of the same
|
|
36230
|
+
* shape: that one replaces a manual override when it is no longer detected,
|
|
36231
|
+
* because Alexa ROUTES on the value. Nothing routes on this one — it is what
|
|
36232
|
+
* the operator already pasted into Google's console — so this only fills the
|
|
36233
|
+
* field when it is empty. `filled` says whether it did.
|
|
36234
|
+
*/
|
|
36235
|
+
detectPublicHubUrl: customAction(object({}).optional(), object({
|
|
36236
|
+
publicHubUrl: string(),
|
|
36237
|
+
detectedPublicHubUrl: string(),
|
|
36238
|
+
filled: boolean()
|
|
36239
|
+
}), { kind: "mutation" }) });
|
|
36240
|
+
//#endregion
|
|
36193
36241
|
//#region src/google-home/trait-catalog.ts
|
|
36194
36242
|
/**
|
|
36195
36243
|
* The trait catalog — one ROW per camstack capability, and that row is the
|
|
@@ -36218,7 +36266,7 @@ var MB = 1024 * 1024;
|
|
|
36218
36266
|
* Cameras. Google's `CameraStream` trait answers `GetCameraStream` with a
|
|
36219
36267
|
* playable URL or a WebRTC signaling endpoint, and neither exists here yet, so
|
|
36220
36268
|
* a camera would be declared and then fail every command. It is omitted rather
|
|
36221
|
-
* than declared — see docs/decisions/adr-
|
|
36269
|
+
* than declared — see docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
|
|
36222
36270
|
*/
|
|
36223
36271
|
/**
|
|
36224
36272
|
* The Google state a SUCCEEDED call establishes.
|
|
@@ -36967,9 +37015,262 @@ function buildGoogleOauthIntegration() {
|
|
|
36967
37015
|
};
|
|
36968
37016
|
}
|
|
36969
37017
|
//#endregion
|
|
37018
|
+
//#region src/public-hub-url.ts
|
|
37019
|
+
/**
|
|
37020
|
+
* The hub's public HTTPS origin — detected, never invented.
|
|
37021
|
+
*
|
|
37022
|
+
* `publicHubUrl` is the single operator-facing value in this addon, and the
|
|
37023
|
+
* three URLs pasted into the Google Home Developer Console (fulfillment,
|
|
37024
|
+
* authorization, token) are all rendered from it. Nothing ROUTES on it: Google
|
|
37025
|
+
* calls the fulfillment URL it was configured with. A wrong value therefore
|
|
37026
|
+
* fails at paste time, in the console, hours before anything here notices —
|
|
37027
|
+
* which is exactly why the field has to say where its content came from.
|
|
37028
|
+
*
|
|
37029
|
+
* ## Where the URL comes from
|
|
37030
|
+
*
|
|
37031
|
+
* The `network-access` capability, same source `addon-export-alexa` uses. It is
|
|
37032
|
+
* a codegen'd collection cap with `scope: 'system'`, so it is reachable
|
|
37033
|
+
* cross-process through `ctx.api` — unlike the `addons` core router, which a
|
|
37034
|
+
* forked addon calling `ctx.api.addons.*` would wait on forever.
|
|
37035
|
+
*
|
|
37036
|
+
* **Not** `CAMSTACK_HUB_PUBLIC_URL`. That variable answers a different
|
|
37037
|
+
* question: which origin the hub should mint its OWN media / model-distribution
|
|
37038
|
+
* links on, and it defaults to `https://127.0.0.1:4443`. It is routinely a
|
|
37039
|
+
* loopback or LAN address, and the backend's `publicHubUrl()` fallback is
|
|
37040
|
+
* localhost in dev. Google calls from its own cloud, so a LAN answer here is
|
|
37041
|
+
* worse than no answer — it produces three console URLs that look complete and
|
|
37042
|
+
* can never be reached.
|
|
37043
|
+
*
|
|
37044
|
+
* ## Why this is a second copy of Alexa's mechanism
|
|
37045
|
+
*
|
|
37046
|
+
* Alexa's lives in `packages/addon-export-alexa/src/export-alexa.addon.ts` as
|
|
37047
|
+
* private methods, and addons never import each other. The only shared home
|
|
37048
|
+
* would be `@camstack/types` or `@camstack/system` — both stay host-resolved in
|
|
37049
|
+
* an addon bundle (`tools/build/vite-lib.preset.ts`), so putting it there would
|
|
37050
|
+
* put this addon on the framework publish train and destroy the one property
|
|
37051
|
+
* that makes it attractive: `camstack deploy packages/addon-export-google` and
|
|
37052
|
+
* nothing else. The SOURCE and the POLICY are Alexa's; two behaviours diverge
|
|
37053
|
+
* on purpose and both are named below.
|
|
37054
|
+
*
|
|
37055
|
+
* ## Two deliberate divergences from Alexa
|
|
37056
|
+
*
|
|
37057
|
+
* 1. **Refresh never clobbers.** Alexa's `redetectPublicHubUrl` replaces a
|
|
37058
|
+
* manual override whenever it is not among the detected endpoints — sound
|
|
37059
|
+
* there, because the URL is baked into every Alexa-bound JWT as a routing
|
|
37060
|
+
* claim and a stale one breaks routing. Here the value is what the operator
|
|
37061
|
+
* already pasted into Google's console; silently rewriting it would leave
|
|
37062
|
+
* the console and the hub disagreeing with no visible cause. Detection fills
|
|
37063
|
+
* an EMPTY field and does nothing else.
|
|
37064
|
+
* 2. **A non-routable origin is dropped.** Alexa forwards whatever the cap
|
|
37065
|
+
* reports. See {@link isPubliclyRoutableOrigin}.
|
|
37066
|
+
*/
|
|
37067
|
+
/** Trailing slashes off, surrounding space off. Nothing else is rewritten. */
|
|
37068
|
+
var normalisePublicHubUrl = (raw) => raw.trim().replace(/\/+$/, "");
|
|
37069
|
+
var LOOPBACK_HOSTS = [
|
|
37070
|
+
"localhost",
|
|
37071
|
+
"127.0.0.1",
|
|
37072
|
+
"::1",
|
|
37073
|
+
"[::1]",
|
|
37074
|
+
"0.0.0.0"
|
|
37075
|
+
];
|
|
37076
|
+
/**
|
|
37077
|
+
* Whether Google's cloud could plausibly reach this origin.
|
|
37078
|
+
*
|
|
37079
|
+
* The `network-access` providers this hub ships (Cloudflare Tunnel, Tailscale
|
|
37080
|
+
* Funnel) publish public FQDNs, but the cap does not promise it: a provider is
|
|
37081
|
+
* free to report an HTTPS ingress on a private address. Defaulting the field to
|
|
37082
|
+
* one of those would hand the operator three console URLs that look finished
|
|
37083
|
+
* and can never answer — strictly worse than an empty field, because an empty
|
|
37084
|
+
* field is visibly unfinished.
|
|
37085
|
+
*
|
|
37086
|
+
* Conservative on purpose: only loopback, RFC1918, link-local, `.local` and
|
|
37087
|
+
* dotless hostnames are refused. A public tunnel hostname always has a dot.
|
|
37088
|
+
*/
|
|
37089
|
+
var isPubliclyRoutableOrigin = (origin) => {
|
|
37090
|
+
let host;
|
|
37091
|
+
try {
|
|
37092
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
37093
|
+
} catch {
|
|
37094
|
+
return false;
|
|
37095
|
+
}
|
|
37096
|
+
if (host.length === 0) return false;
|
|
37097
|
+
if (LOOPBACK_HOSTS.includes(host)) return false;
|
|
37098
|
+
if (host.startsWith("127.") || host.startsWith("169.254.")) return false;
|
|
37099
|
+
if (host.startsWith("10.") || host.startsWith("192.168.")) return false;
|
|
37100
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
|
|
37101
|
+
if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".lan")) return false;
|
|
37102
|
+
if (!host.includes(".")) return false;
|
|
37103
|
+
return true;
|
|
37104
|
+
};
|
|
37105
|
+
/**
|
|
37106
|
+
* Every publicly routable HTTPS origin the `network-access` cap reports, in the
|
|
37107
|
+
* cap's own order, deduped. Empty when there is none — the caller leaves the
|
|
37108
|
+
* field alone rather than inventing a value.
|
|
37109
|
+
*/
|
|
37110
|
+
var detectPublicHubOrigins = async (deps) => {
|
|
37111
|
+
const fromList = await originsFromListEndpoints(deps);
|
|
37112
|
+
if (fromList.length > 0) return fromList;
|
|
37113
|
+
const fromStatus = await originFromGetStatus(deps);
|
|
37114
|
+
if (fromStatus.length > 0) return [fromStatus];
|
|
37115
|
+
deps.logger.warn("export-google: no connected HTTPS external address — the public hub URL cannot be derived, so the Google console URLs stay unrendered. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …) and press \"Detect external address\", or type the URL on the settings form.");
|
|
37116
|
+
return [];
|
|
37117
|
+
};
|
|
37118
|
+
var usableOrigins = (candidates, logger) => {
|
|
37119
|
+
const out = /* @__PURE__ */ new Set();
|
|
37120
|
+
for (const candidate of candidates) {
|
|
37121
|
+
if (candidate.protocol !== "https" || candidate.url.length === 0) continue;
|
|
37122
|
+
const origin = normalisePublicHubUrl(candidate.url);
|
|
37123
|
+
if (!isPubliclyRoutableOrigin(origin)) {
|
|
37124
|
+
logger.info("export-google: ignored an external address that is not reachable from the internet — Google calls the fulfillment URL from its own cloud", { meta: { origin } });
|
|
37125
|
+
continue;
|
|
37126
|
+
}
|
|
37127
|
+
out.add(origin);
|
|
37128
|
+
}
|
|
37129
|
+
return [...out];
|
|
37130
|
+
};
|
|
37131
|
+
var originsFromListEndpoints = async (deps) => {
|
|
37132
|
+
try {
|
|
37133
|
+
return usableOrigins(await deps.network.listEndpoints(), deps.logger);
|
|
37134
|
+
} catch (err) {
|
|
37135
|
+
deps.logger.debug("export-google: networkAccess.listEndpoints failed", { meta: { error: errMsg(err) } });
|
|
37136
|
+
return [];
|
|
37137
|
+
}
|
|
37138
|
+
};
|
|
37139
|
+
var originFromGetStatus = async (deps) => {
|
|
37140
|
+
try {
|
|
37141
|
+
const status = await deps.network.getStatus();
|
|
37142
|
+
if (status === null || !status.connected || status.endpoint === null) return "";
|
|
37143
|
+
return usableOrigins([status.endpoint], deps.logger)[0] ?? "";
|
|
37144
|
+
} catch (err) {
|
|
37145
|
+
deps.logger.debug("export-google: networkAccess.getStatus failed", { meta: { error: errMsg(err) } });
|
|
37146
|
+
return "";
|
|
37147
|
+
}
|
|
37148
|
+
};
|
|
37149
|
+
/**
|
|
37150
|
+
* Apply the default-not-override policy.
|
|
37151
|
+
*
|
|
37152
|
+
* The operator's value is never in the returned patch. Detection's own answer
|
|
37153
|
+
* always is, because the settings panel needs it to say "you set this one, and
|
|
37154
|
+
* a different one is detected".
|
|
37155
|
+
*/
|
|
37156
|
+
var resolvePublicHubUrl = (current, detected) => {
|
|
37157
|
+
const detectedPublicHubUrl = detected[0] ?? "";
|
|
37158
|
+
if (current.length > 0) return {
|
|
37159
|
+
patch: { detectedPublicHubUrl },
|
|
37160
|
+
publicHubUrl: current,
|
|
37161
|
+
detectedPublicHubUrl,
|
|
37162
|
+
defaulted: false
|
|
37163
|
+
};
|
|
37164
|
+
if (detectedPublicHubUrl.length === 0) return {
|
|
37165
|
+
patch: { detectedPublicHubUrl },
|
|
37166
|
+
publicHubUrl: "",
|
|
37167
|
+
detectedPublicHubUrl,
|
|
37168
|
+
defaulted: false
|
|
37169
|
+
};
|
|
37170
|
+
return {
|
|
37171
|
+
patch: {
|
|
37172
|
+
publicHubUrl: detectedPublicHubUrl,
|
|
37173
|
+
detectedPublicHubUrl
|
|
37174
|
+
},
|
|
37175
|
+
publicHubUrl: detectedPublicHubUrl,
|
|
37176
|
+
detectedPublicHubUrl,
|
|
37177
|
+
defaulted: true
|
|
37178
|
+
};
|
|
37179
|
+
};
|
|
37180
|
+
/** Which of the two authored the value currently in the field. */
|
|
37181
|
+
var publicHubUrlSource = (state) => {
|
|
37182
|
+
if (state.publicHubUrl.length === 0) return "unset";
|
|
37183
|
+
if (state.detectedPublicHubUrl.length > 0 && state.publicHubUrl === state.detectedPublicHubUrl) return "derived";
|
|
37184
|
+
return "operator-set";
|
|
37185
|
+
};
|
|
37186
|
+
/**
|
|
37187
|
+
* The line under the field. A URL that appeared by itself with no provenance is
|
|
37188
|
+
* worse than a blank field: the operator cannot tell whether it is right.
|
|
37189
|
+
*/
|
|
37190
|
+
var buildPublicHubUrlNotice = (state) => {
|
|
37191
|
+
switch (publicHubUrlSource(state)) {
|
|
37192
|
+
case "derived": return {
|
|
37193
|
+
variant: "info",
|
|
37194
|
+
content: `Derived from remote access — ${state.publicHubUrl} is the first HTTPS address the network-access capability reports. Edit the field to override it; nothing here ever overwrites a value you set.`
|
|
37195
|
+
};
|
|
37196
|
+
case "operator-set": return {
|
|
37197
|
+
variant: "info",
|
|
37198
|
+
content: state.detectedPublicHubUrl.length > 0 ? `Set by you — ${state.publicHubUrl}. The address currently detected from remote access is ${state.detectedPublicHubUrl}; paste it in yourself if you want to switch, detection will not do it for you.` : `Set by you — ${state.publicHubUrl}. No external HTTPS address is detected right now, so nothing is corroborating it.`
|
|
37199
|
+
};
|
|
37200
|
+
case "unset": return state.detectedPublicHubUrl.length > 0 ? {
|
|
37201
|
+
variant: "info",
|
|
37202
|
+
content: `Detected external address: ${state.detectedPublicHubUrl}. Press "Detect external address" to fill the field with it.`
|
|
37203
|
+
} : {
|
|
37204
|
+
variant: "warning",
|
|
37205
|
+
content: "No public HTTPS address detected — no network-access provider on this hub reports a connected HTTPS endpoint, so there is nothing to derive from. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …) and press \"Detect external address\", or type the URL yourself. An address on your own network will not do: Google calls this URL from its own cloud."
|
|
37206
|
+
};
|
|
37207
|
+
}
|
|
37208
|
+
};
|
|
37209
|
+
/**
|
|
37210
|
+
* The `device-export` setup block: the three console URLs plus the
|
|
37211
|
+
* linked-account count. All three come off the SAME origin, so they can never
|
|
37212
|
+
* disagree with each other.
|
|
37213
|
+
*/
|
|
37214
|
+
var buildConsoleSetup = (input) => {
|
|
37215
|
+
const origin = normalisePublicHubUrl(input.publicHubUrl);
|
|
37216
|
+
const linked = String(input.linkedAccounts);
|
|
37217
|
+
if (origin.length === 0) return {
|
|
37218
|
+
note: ["Set the public hub URL on the settings form first — every URL Google needs is derived from it.", buildPublicHubUrlNotice(input).content].join("\n"),
|
|
37219
|
+
fields: [{
|
|
37220
|
+
label: "Linked Google accounts",
|
|
37221
|
+
value: linked
|
|
37222
|
+
}]
|
|
37223
|
+
};
|
|
37224
|
+
return {
|
|
37225
|
+
note: [
|
|
37226
|
+
"In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
|
|
37227
|
+
"The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
|
|
37228
|
+
"This hub pushes nothing to Google — state is read by polling, so a change made outside the Home app appears on the next query, not instantly.",
|
|
37229
|
+
buildPublicHubUrlNotice(input).content
|
|
37230
|
+
].join("\n"),
|
|
37231
|
+
fields: [
|
|
37232
|
+
{
|
|
37233
|
+
label: "Fulfillment URL",
|
|
37234
|
+
value: `${origin}/addon/${input.addonId}/fulfillment`
|
|
37235
|
+
},
|
|
37236
|
+
{
|
|
37237
|
+
label: "Authorization URL",
|
|
37238
|
+
value: `${origin}/api/oauth2/authorize?integration=${input.addonId}`
|
|
37239
|
+
},
|
|
37240
|
+
{
|
|
37241
|
+
label: "Token URL",
|
|
37242
|
+
value: `${origin}/api/oauth2/token`
|
|
37243
|
+
},
|
|
37244
|
+
{
|
|
37245
|
+
label: "Linked Google accounts",
|
|
37246
|
+
value: linked
|
|
37247
|
+
}
|
|
37248
|
+
]
|
|
37249
|
+
};
|
|
37250
|
+
};
|
|
37251
|
+
/**
|
|
37252
|
+
* Detect, apply the policy, persist, and say out loud what happened.
|
|
37253
|
+
*
|
|
37254
|
+
* Every branch here logs: a fill names the URL it picked and the source it came
|
|
37255
|
+
* from, and a miss that leaves the field empty says so rather than passing for
|
|
37256
|
+
* "never ran".
|
|
37257
|
+
*/
|
|
37258
|
+
var refreshPublicHubUrl = async (deps) => {
|
|
37259
|
+
const detected = await deps.detect();
|
|
37260
|
+
const resolution = resolvePublicHubUrl(deps.current().publicHubUrl, detected);
|
|
37261
|
+
await deps.persist(resolution.patch);
|
|
37262
|
+
if (resolution.defaulted) deps.logger.info("export-google: defaulted public hub URL to the detected external address", { meta: {
|
|
37263
|
+
publicHubUrl: resolution.publicHubUrl,
|
|
37264
|
+
source: "network-access"
|
|
37265
|
+
} });
|
|
37266
|
+
else if (resolution.publicHubUrl.length === 0) deps.logger.warn("export-google: public hub URL still unset after detection — the Google console URLs cannot be rendered until one is available", { meta: { detectedCount: detected.length } });
|
|
37267
|
+
return resolution;
|
|
37268
|
+
};
|
|
37269
|
+
//#endregion
|
|
36970
37270
|
//#region src/types.ts
|
|
36971
37271
|
var DEFAULT_SETTINGS = {
|
|
36972
37272
|
publicHubUrl: "",
|
|
37273
|
+
detectedPublicHubUrl: "",
|
|
36973
37274
|
exposed: [],
|
|
36974
37275
|
linkedAccounts: []
|
|
36975
37276
|
};
|
|
@@ -37008,7 +37309,7 @@ var DEFAULT_SETTINGS = {
|
|
|
37008
37309
|
*
|
|
37009
37310
|
* Switches, dimmable lights, locks and covers — the non-camera fleet neither
|
|
37010
37311
|
* `export-hap` nor `export-alexa` covers. Cameras are out of scope on purpose
|
|
37011
|
-
* (docs/decisions/adr-
|
|
37312
|
+
* (docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md); the trait catalog is the single place that
|
|
37012
37313
|
* decides, so widening scope is one row.
|
|
37013
37314
|
*/
|
|
37014
37315
|
var ADDON_ID = "export-google";
|
|
@@ -37023,6 +37324,9 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37023
37324
|
logger: this.ctx.logger
|
|
37024
37325
|
});
|
|
37025
37326
|
this.gateway = gateway;
|
|
37327
|
+
this.detectPublicHubUrl().catch((err) => {
|
|
37328
|
+
this.ctx.logger.warn("export-google: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
37329
|
+
});
|
|
37026
37330
|
const deviceExportProvider = {
|
|
37027
37331
|
getStatus: async () => ({
|
|
37028
37332
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -37062,26 +37366,93 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37062
37366
|
}
|
|
37063
37367
|
}));
|
|
37064
37368
|
const oauthIntegrationProvider = { getDescriptor: async () => buildGoogleOauthIntegration() };
|
|
37369
|
+
const offTunnelStarted = this.ctx.eventBus.subscribe({ category: EventCategory.NetworkTunnelStarted }, () => {
|
|
37370
|
+
this.detectPublicHubUrl().catch((err) => {
|
|
37371
|
+
this.ctx.logger.warn("export-google: public hub URL detect after tunnel connect failed", { meta: { error: errMsg(err) } });
|
|
37372
|
+
});
|
|
37373
|
+
});
|
|
37374
|
+
this.ctx.addDisposer(async () => offTunnelStarted());
|
|
37065
37375
|
this.ctx.logger.info("export-google: initialized", { meta: {
|
|
37066
37376
|
exposedCount: this.config.exposed.length,
|
|
37067
37377
|
linkedAccounts: this.config.linkedAccounts.length,
|
|
37068
37378
|
publicHubUrlSet: this.config.publicHubUrl.length > 0,
|
|
37069
37379
|
willReportState: false
|
|
37070
37380
|
} });
|
|
37071
|
-
return {
|
|
37072
|
-
|
|
37073
|
-
|
|
37074
|
-
|
|
37075
|
-
|
|
37076
|
-
|
|
37077
|
-
|
|
37078
|
-
|
|
37381
|
+
return {
|
|
37382
|
+
providers: [
|
|
37383
|
+
{
|
|
37384
|
+
capability: deviceExportCapability,
|
|
37385
|
+
provider: deviceExportProvider
|
|
37386
|
+
},
|
|
37387
|
+
{
|
|
37388
|
+
capability: addonRoutesCapability,
|
|
37389
|
+
provider: routeProvider
|
|
37390
|
+
},
|
|
37391
|
+
{
|
|
37392
|
+
capability: oauthIntegrationCapability,
|
|
37393
|
+
provider: oauthIntegrationProvider
|
|
37394
|
+
}
|
|
37395
|
+
],
|
|
37396
|
+
customActions: exportGoogleActions,
|
|
37397
|
+
actionHandlers: { detectPublicHubUrl: async () => this.detectPublicHubUrl() }
|
|
37398
|
+
};
|
|
37399
|
+
}
|
|
37400
|
+
/** This addon's slice of `PublicHubUrlState`. */
|
|
37401
|
+
publicHubUrlState() {
|
|
37402
|
+
return {
|
|
37403
|
+
publicHubUrl: this.config.publicHubUrl,
|
|
37404
|
+
detectedPublicHubUrl: this.config.detectedPublicHubUrl
|
|
37405
|
+
};
|
|
37406
|
+
}
|
|
37407
|
+
/**
|
|
37408
|
+
* Adapter from the codegen'd `network-access` router onto the narrow reader
|
|
37409
|
+
* `public-hub-url.ts` consumes. Fields are copied one by one rather than
|
|
37410
|
+
* passed through, so the module never depends on the cap's wider shape and a
|
|
37411
|
+
* test double for it needs no cast.
|
|
37412
|
+
*/
|
|
37413
|
+
networkAccessReader() {
|
|
37414
|
+
return {
|
|
37415
|
+
listEndpoints: async () => {
|
|
37416
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query({})).map((entry) => ({
|
|
37417
|
+
url: entry.url,
|
|
37418
|
+
protocol: entry.protocol
|
|
37419
|
+
}));
|
|
37079
37420
|
},
|
|
37080
|
-
{
|
|
37081
|
-
|
|
37082
|
-
|
|
37421
|
+
getStatus: async () => {
|
|
37422
|
+
const status = await this.ctx.api.networkAccess.getStatus.query({});
|
|
37423
|
+
return {
|
|
37424
|
+
connected: status.connected,
|
|
37425
|
+
endpoint: status.endpoint === null ? null : {
|
|
37426
|
+
url: status.endpoint.url,
|
|
37427
|
+
protocol: status.endpoint.protocol
|
|
37428
|
+
}
|
|
37429
|
+
};
|
|
37083
37430
|
}
|
|
37084
|
-
|
|
37431
|
+
};
|
|
37432
|
+
}
|
|
37433
|
+
/**
|
|
37434
|
+
* Refresh the detected address and DEFAULT the field to it when it is empty.
|
|
37435
|
+
* Backs both the boot-time detect and the "Detect external address" button.
|
|
37436
|
+
*
|
|
37437
|
+
* Never replaces a value the operator set — that value is what they already
|
|
37438
|
+
* pasted into the Google console, and rewriting it here would leave the two
|
|
37439
|
+
* sides disagreeing with nothing on screen to say so.
|
|
37440
|
+
*/
|
|
37441
|
+
async detectPublicHubUrl() {
|
|
37442
|
+
const resolution = await refreshPublicHubUrl({
|
|
37443
|
+
current: () => this.publicHubUrlState(),
|
|
37444
|
+
detect: () => detectPublicHubOrigins({
|
|
37445
|
+
network: this.networkAccessReader(),
|
|
37446
|
+
logger: this.ctx.logger
|
|
37447
|
+
}),
|
|
37448
|
+
persist: (patch) => this.updateGlobalSettings(patch),
|
|
37449
|
+
logger: this.ctx.logger
|
|
37450
|
+
});
|
|
37451
|
+
return {
|
|
37452
|
+
publicHubUrl: resolution.publicHubUrl,
|
|
37453
|
+
detectedPublicHubUrl: resolution.detectedPublicHubUrl,
|
|
37454
|
+
filled: resolution.defaulted
|
|
37455
|
+
};
|
|
37085
37456
|
}
|
|
37086
37457
|
async onShutdown() {
|
|
37087
37458
|
this.gateway = null;
|
|
@@ -37151,65 +37522,54 @@ var ExportGoogleAddon = class extends BaseAddon {
|
|
|
37151
37522
|
* tool for a real credential anyway (docs/decisions/adr-0269-a-google-export-that-holds-no-google-credential.md).
|
|
37152
37523
|
*/
|
|
37153
37524
|
buildSetupBlock() {
|
|
37154
|
-
|
|
37155
|
-
|
|
37156
|
-
|
|
37157
|
-
|
|
37158
|
-
|
|
37159
|
-
|
|
37160
|
-
|
|
37161
|
-
|
|
37162
|
-
|
|
37163
|
-
|
|
37164
|
-
|
|
37165
|
-
|
|
37166
|
-
"The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
|
|
37167
|
-
"This hub pushes nothing to Google — state is read by polling, so a change made outside the Home app appears on the next query, not instantly."
|
|
37168
|
-
].join("\n"),
|
|
37525
|
+
return buildConsoleSetup({
|
|
37526
|
+
addonId: ADDON_ID,
|
|
37527
|
+
...this.publicHubUrlState(),
|
|
37528
|
+
linkedAccounts: this.config.linkedAccounts.length
|
|
37529
|
+
});
|
|
37530
|
+
}
|
|
37531
|
+
globalSettingsSchema() {
|
|
37532
|
+
return this.schema({ sections: [{
|
|
37533
|
+
id: ADDON_ID,
|
|
37534
|
+
title: "Google Home export",
|
|
37535
|
+
description: "Publishes switches, dimmable lights, locks and covers to Google Home. The hub answers Google directly — no cloud function, and no Google credential is stored here.",
|
|
37536
|
+
columns: 1,
|
|
37169
37537
|
fields: [
|
|
37170
37538
|
{
|
|
37171
|
-
|
|
37172
|
-
|
|
37173
|
-
|
|
37174
|
-
|
|
37175
|
-
|
|
37176
|
-
|
|
37539
|
+
type: "info",
|
|
37540
|
+
key: "__google-setup-banner",
|
|
37541
|
+
label: "Setup overview",
|
|
37542
|
+
variant: "info",
|
|
37543
|
+
content: [
|
|
37544
|
+
"This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
|
|
37545
|
+
"Create a cloud-to-cloud integration in the Google Home Developer Console, paste the three URLs from the Export panel, and link your account from the Home app.",
|
|
37546
|
+
"Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
|
|
37547
|
+
].join("\n")
|
|
37177
37548
|
},
|
|
37549
|
+
this.field({
|
|
37550
|
+
type: "text",
|
|
37551
|
+
key: "publicHubUrl",
|
|
37552
|
+
label: "Public hub URL",
|
|
37553
|
+
description: "HTTPS origin Google reaches this hub on, e.g. https://hub.example.com. Used to render the URLs you paste into the Google console. Left empty, it defaults to the first external address remote access reports; once it holds a value, nothing overwrites it.",
|
|
37554
|
+
placeholder: "https://hub.example.com",
|
|
37555
|
+
default: DEFAULT_SETTINGS.publicHubUrl
|
|
37556
|
+
}),
|
|
37178
37557
|
{
|
|
37179
|
-
|
|
37180
|
-
|
|
37558
|
+
type: "info",
|
|
37559
|
+
key: "__public-hub-url-source",
|
|
37560
|
+
label: "Where this came from",
|
|
37561
|
+
...buildPublicHubUrlNotice(this.publicHubUrlState())
|
|
37181
37562
|
},
|
|
37182
37563
|
{
|
|
37183
|
-
|
|
37184
|
-
|
|
37564
|
+
type: "button",
|
|
37565
|
+
key: "__detect-public-hub-url",
|
|
37566
|
+
label: "External address",
|
|
37567
|
+
description: "Re-scan the connected remote-access providers. Fills the field above when it is empty; a value you set is left exactly as it is.",
|
|
37568
|
+
buttonLabel: "Detect external address",
|
|
37569
|
+
action: "detectPublicHubUrl",
|
|
37570
|
+
variant: "default"
|
|
37185
37571
|
}
|
|
37186
37572
|
]
|
|
37187
|
-
};
|
|
37188
|
-
}
|
|
37189
|
-
globalSettingsSchema() {
|
|
37190
|
-
return this.schema({ sections: [{
|
|
37191
|
-
id: ADDON_ID,
|
|
37192
|
-
title: "Google Home export",
|
|
37193
|
-
description: "Publishes switches, dimmable lights, locks and covers to Google Home. The hub answers Google directly — no cloud function, and no Google credential is stored here.",
|
|
37194
|
-
columns: 1,
|
|
37195
|
-
fields: [{
|
|
37196
|
-
type: "info",
|
|
37197
|
-
key: "__google-setup-banner",
|
|
37198
|
-
label: "Setup overview",
|
|
37199
|
-
variant: "info",
|
|
37200
|
-
content: [
|
|
37201
|
-
"This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
|
|
37202
|
-
"Create a cloud-to-cloud integration in the Google Home Developer Console, paste the three URLs from the Export panel, and link your account from the Home app.",
|
|
37203
|
-
"Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
|
|
37204
|
-
].join("\n")
|
|
37205
|
-
}, this.field({
|
|
37206
|
-
type: "text",
|
|
37207
|
-
key: "publicHubUrl",
|
|
37208
|
-
label: "Public hub URL",
|
|
37209
|
-
description: "HTTPS origin Google reaches this hub on, e.g. https://hub.example.com. Used to render the URLs you paste into the Google console.",
|
|
37210
|
-
placeholder: "https://hub.example.com",
|
|
37211
|
-
default: DEFAULT_SETTINGS.publicHubUrl
|
|
37212
|
-
})]
|
|
37213
37573
|
}] });
|
|
37214
37574
|
}
|
|
37215
37575
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-google",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Google Home export — hub-side smart-home fulfillment (SYNC / QUERY / EXECUTE / DISCONNECT) for the non-camera fleet, served over the hub's own OAuth account link. No Google credential is stored, sent or required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|