@camstack/addon-export-google 0.1.2 → 0.1.5

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.
@@ -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
@@ -13862,6 +13884,50 @@ var NodeProcessSchema = object({
13862
13884
  /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
13863
13885
  uptimeSec: number()
13864
13886
  });
13887
+ /**
13888
+ * One retained container-memory reading.
13889
+ *
13890
+ * `atMs` is the timestamp of the PROCESS snapshot taken in the same tick, not
13891
+ * a second clock: that is what makes "processes sum to X, container says Y"
13892
+ * subtractable per point rather than an eyeballed comparison of two series
13893
+ * sampled at different instants.
13894
+ *
13895
+ * A reduced window keeps the sample with the LARGEST `currentBytes` in each
13896
+ * bucket, WHOLE. Taking a per-field maximum would synthesise a row whose parts
13897
+ * never coexisted, and a mean would smear away the peak this exists to find.
13898
+ */
13899
+ var ContainerMemoryPointSchema = object({
13900
+ /** Which hierarchy answered, so a reading is never ambiguous. */
13901
+ source: _enum(["cgroup-v2", "cgroup-v1"]),
13902
+ /** `memory.current` (v2) / `memory.usage_in_bytes` (v1). Always known. */
13903
+ currentBytes: number(),
13904
+ /** The cgroup's ceiling. `null` = NO LIMIT — never a sentinel, never zero. */
13905
+ limitBytes: number().nullable(),
13906
+ /** Anonymous pages: the closest thing to "what the processes allocated". */
13907
+ anonBytes: number().nullable(),
13908
+ /** Page cache. Charged to the cgroup, owned by no process. */
13909
+ fileBytes: number().nullable(),
13910
+ /**
13911
+ * Shared memory — and the field that explained the largest single surprise.
13912
+ * The i915 driver backs GPU buffers with shmem, so an inference pool or a
13913
+ * hardware-decode session holding DRM objects is charged HERE and appears
13914
+ * nowhere in a `ps` scan.
13915
+ */
13916
+ shmemBytes: number().nullable(),
13917
+ /** Kernel slab charged to this cgroup. `null` on v1, which never publishes it. */
13918
+ slabBytes: number().nullable(),
13919
+ /**
13920
+ * Shrinkable i915 GEM object bytes, from debugfs.
13921
+ *
13922
+ * **Host-wide across every DRM client, NOT cgroup-scoped.** It is not a
13923
+ * component of `currentBytes` and must not be subtracted from it; it says
13924
+ * what put the shmem there, where `shmemBytes` only says how much.
13925
+ *
13926
+ * `null` wherever debugfs is not mounted — which is inside every camstack
13927
+ * container today — and on any node with no Intel GPU.
13928
+ */
13929
+ gpuShmemBytes: number().nullable()
13930
+ }).extend({ atMs: number() });
13865
13931
  var DumpHeapSnapshotInputSchema = object({
13866
13932
  /** The addon whose runner should dump a heap snapshot. */
13867
13933
  addonId: string() });
@@ -13925,6 +13991,21 @@ var NodeLoadSeriesSchema = object({
13925
13991
  /** One entry per function seen in the window, heaviest-first. */
13926
13992
  series: array(LoadFunctionSeriesSchema).readonly(),
13927
13993
  /**
13994
+ * The CONTAINER's memory over the same window, oldest-first.
13995
+ *
13996
+ * Sits next to `series` rather than in a method of its own because the whole
13997
+ * question is a subtraction: the per-process rows in `series` sum to one
13998
+ * number and this one is another, and an operator who has to issue two calls
13999
+ * to compare them will compare two different instants. Same reader, same
14000
+ * `sinceMs`, same `bucketMs`, same timestamps.
14001
+ *
14002
+ * **EMPTY means ABSENT, never zero.** A node with no cgroup — a developer
14003
+ * Mac, a bare-metal host, a container with the hierarchy hidden — reports no
14004
+ * points at all. A zero here would be indistinguishable from a healthy
14005
+ * container and is precisely the lie this field exists to avoid.
14006
+ */
14007
+ containerMemory: array(ContainerMemoryPointSchema).readonly(),
14008
+ /**
13928
14009
  * Width of one returned bucket, in ms. Equals the sampling cadence when no
13929
14010
  * reduction was needed — so a caller can always say what one point covers
13930
14011
  * without having to know whether it was reduced.
@@ -25882,10 +25963,10 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceI
25882
25963
  * recording config. NOTE on events (source of truth, R5/C3): this cap carries
25883
25964
  * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
25884
25965
  * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
25885
- * rows); the recorder's internal EventMap markers are ephemeral in-RAM
25886
- * annotations that are not exposed here and must not be treated as an event
25887
- * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
25888
- * (`interfaces/recording-config.ts`).
25966
+ * rows) and are the ONLY event surface the recorder has none. The in-RAM
25967
+ * playback markers it used to build were deleted on 2026-08-29 because nothing
25968
+ * ever read them. Event<->footage joins are by time, padded with the shared
25969
+ * `EVENT_PAD_MS` (`interfaces/recording-config.ts`).
25889
25970
  */
25890
25971
  var RecordingStatusSchema = object({
25891
25972
  deviceId: number(),
@@ -36194,6 +36275,32 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
36194
36275
  var MB = 1024 * 1024;
36195
36276
  1024 * MB, 3072 * MB;
36196
36277
  //#endregion
36278
+ //#region src/custom-actions.ts
36279
+ /**
36280
+ * Google Home export — customActions catalog.
36281
+ *
36282
+ * One entry. The settings form's `type: 'button'` field dispatches through the
36283
+ * generic `api.addons.custom.mutate({ addonId, action, input })` channel, so a
36284
+ * button here costs no capability, no codegen and no framework publish train —
36285
+ * the catalog travels inside this addon's own bundle.
36286
+ */
36287
+ var exportGoogleActions = defineCustomActions({
36288
+ /**
36289
+ * Re-scan the connected `network-access` providers and refresh the address
36290
+ * shown under the field.
36291
+ *
36292
+ * Deliberately NOT the same contract as `export-alexa`'s action of the same
36293
+ * shape: that one replaces a manual override when it is no longer detected,
36294
+ * because Alexa ROUTES on the value. Nothing routes on this one — it is what
36295
+ * the operator already pasted into Google's console — so this only fills the
36296
+ * field when it is empty. `filled` says whether it did.
36297
+ */
36298
+ detectPublicHubUrl: customAction(object({}).optional(), object({
36299
+ publicHubUrl: string(),
36300
+ detectedPublicHubUrl: string(),
36301
+ filled: boolean()
36302
+ }), { kind: "mutation" }) });
36303
+ //#endregion
36197
36304
  //#region src/google-home/trait-catalog.ts
36198
36305
  /**
36199
36306
  * The trait catalog — one ROW per camstack capability, and that row is the
@@ -36222,7 +36329,7 @@ var MB = 1024 * 1024;
36222
36329
  * Cameras. Google's `CameraStream` trait answers `GetCameraStream` with a
36223
36330
  * playable URL or a WebRTC signaling endpoint, and neither exists here yet, so
36224
36331
  * a camera would be declared and then fail every command. It is omitted rather
36225
- * than declared — see docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36332
+ * than declared — see docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36226
36333
  */
36227
36334
  /**
36228
36335
  * The Google state a SUCCEEDED call establishes.
@@ -36971,9 +37078,262 @@ function buildGoogleOauthIntegration() {
36971
37078
  };
36972
37079
  }
36973
37080
  //#endregion
37081
+ //#region src/public-hub-url.ts
37082
+ /**
37083
+ * The hub's public HTTPS origin — detected, never invented.
37084
+ *
37085
+ * `publicHubUrl` is the single operator-facing value in this addon, and the
37086
+ * three URLs pasted into the Google Home Developer Console (fulfillment,
37087
+ * authorization, token) are all rendered from it. Nothing ROUTES on it: Google
37088
+ * calls the fulfillment URL it was configured with. A wrong value therefore
37089
+ * fails at paste time, in the console, hours before anything here notices —
37090
+ * which is exactly why the field has to say where its content came from.
37091
+ *
37092
+ * ## Where the URL comes from
37093
+ *
37094
+ * The `network-access` capability, same source `addon-export-alexa` uses. It is
37095
+ * a codegen'd collection cap with `scope: 'system'`, so it is reachable
37096
+ * cross-process through `ctx.api` — unlike the `addons` core router, which a
37097
+ * forked addon calling `ctx.api.addons.*` would wait on forever.
37098
+ *
37099
+ * **Not** `CAMSTACK_HUB_PUBLIC_URL`. That variable answers a different
37100
+ * question: which origin the hub should mint its OWN media / model-distribution
37101
+ * links on, and it defaults to `https://127.0.0.1:4443`. It is routinely a
37102
+ * loopback or LAN address, and the backend's `publicHubUrl()` fallback is
37103
+ * localhost in dev. Google calls from its own cloud, so a LAN answer here is
37104
+ * worse than no answer — it produces three console URLs that look complete and
37105
+ * can never be reached.
37106
+ *
37107
+ * ## Why this is a second copy of Alexa's mechanism
37108
+ *
37109
+ * Alexa's lives in `packages/addon-export-alexa/src/export-alexa.addon.ts` as
37110
+ * private methods, and addons never import each other. The only shared home
37111
+ * would be `@camstack/types` or `@camstack/system` — both stay host-resolved in
37112
+ * an addon bundle (`tools/build/vite-lib.preset.ts`), so putting it there would
37113
+ * put this addon on the framework publish train and destroy the one property
37114
+ * that makes it attractive: `camstack deploy packages/addon-export-google` and
37115
+ * nothing else. The SOURCE and the POLICY are Alexa's; two behaviours diverge
37116
+ * on purpose and both are named below.
37117
+ *
37118
+ * ## Two deliberate divergences from Alexa
37119
+ *
37120
+ * 1. **Refresh never clobbers.** Alexa's `redetectPublicHubUrl` replaces a
37121
+ * manual override whenever it is not among the detected endpoints — sound
37122
+ * there, because the URL is baked into every Alexa-bound JWT as a routing
37123
+ * claim and a stale one breaks routing. Here the value is what the operator
37124
+ * already pasted into Google's console; silently rewriting it would leave
37125
+ * the console and the hub disagreeing with no visible cause. Detection fills
37126
+ * an EMPTY field and does nothing else.
37127
+ * 2. **A non-routable origin is dropped.** Alexa forwards whatever the cap
37128
+ * reports. See {@link isPubliclyRoutableOrigin}.
37129
+ */
37130
+ /** Trailing slashes off, surrounding space off. Nothing else is rewritten. */
37131
+ var normalisePublicHubUrl = (raw) => raw.trim().replace(/\/+$/, "");
37132
+ var LOOPBACK_HOSTS = [
37133
+ "localhost",
37134
+ "127.0.0.1",
37135
+ "::1",
37136
+ "[::1]",
37137
+ "0.0.0.0"
37138
+ ];
37139
+ /**
37140
+ * Whether Google's cloud could plausibly reach this origin.
37141
+ *
37142
+ * The `network-access` providers this hub ships (Cloudflare Tunnel, Tailscale
37143
+ * Funnel) publish public FQDNs, but the cap does not promise it: a provider is
37144
+ * free to report an HTTPS ingress on a private address. Defaulting the field to
37145
+ * one of those would hand the operator three console URLs that look finished
37146
+ * and can never answer — strictly worse than an empty field, because an empty
37147
+ * field is visibly unfinished.
37148
+ *
37149
+ * Conservative on purpose: only loopback, RFC1918, link-local, `.local` and
37150
+ * dotless hostnames are refused. A public tunnel hostname always has a dot.
37151
+ */
37152
+ var isPubliclyRoutableOrigin = (origin) => {
37153
+ let host;
37154
+ try {
37155
+ host = new URL(origin).hostname.toLowerCase();
37156
+ } catch {
37157
+ return false;
37158
+ }
37159
+ if (host.length === 0) return false;
37160
+ if (LOOPBACK_HOSTS.includes(host)) return false;
37161
+ if (host.startsWith("127.") || host.startsWith("169.254.")) return false;
37162
+ if (host.startsWith("10.") || host.startsWith("192.168.")) return false;
37163
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
37164
+ if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".lan")) return false;
37165
+ if (!host.includes(".")) return false;
37166
+ return true;
37167
+ };
37168
+ /**
37169
+ * Every publicly routable HTTPS origin the `network-access` cap reports, in the
37170
+ * cap's own order, deduped. Empty when there is none — the caller leaves the
37171
+ * field alone rather than inventing a value.
37172
+ */
37173
+ var detectPublicHubOrigins = async (deps) => {
37174
+ const fromList = await originsFromListEndpoints(deps);
37175
+ if (fromList.length > 0) return fromList;
37176
+ const fromStatus = await originFromGetStatus(deps);
37177
+ if (fromStatus.length > 0) return [fromStatus];
37178
+ 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.");
37179
+ return [];
37180
+ };
37181
+ var usableOrigins = (candidates, logger) => {
37182
+ const out = /* @__PURE__ */ new Set();
37183
+ for (const candidate of candidates) {
37184
+ if (candidate.protocol !== "https" || candidate.url.length === 0) continue;
37185
+ const origin = normalisePublicHubUrl(candidate.url);
37186
+ if (!isPubliclyRoutableOrigin(origin)) {
37187
+ 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 } });
37188
+ continue;
37189
+ }
37190
+ out.add(origin);
37191
+ }
37192
+ return [...out];
37193
+ };
37194
+ var originsFromListEndpoints = async (deps) => {
37195
+ try {
37196
+ return usableOrigins(await deps.network.listEndpoints(), deps.logger);
37197
+ } catch (err) {
37198
+ deps.logger.debug("export-google: networkAccess.listEndpoints failed", { meta: { error: errMsg(err) } });
37199
+ return [];
37200
+ }
37201
+ };
37202
+ var originFromGetStatus = async (deps) => {
37203
+ try {
37204
+ const status = await deps.network.getStatus();
37205
+ if (status === null || !status.connected || status.endpoint === null) return "";
37206
+ return usableOrigins([status.endpoint], deps.logger)[0] ?? "";
37207
+ } catch (err) {
37208
+ deps.logger.debug("export-google: networkAccess.getStatus failed", { meta: { error: errMsg(err) } });
37209
+ return "";
37210
+ }
37211
+ };
37212
+ /**
37213
+ * Apply the default-not-override policy.
37214
+ *
37215
+ * The operator's value is never in the returned patch. Detection's own answer
37216
+ * always is, because the settings panel needs it to say "you set this one, and
37217
+ * a different one is detected".
37218
+ */
37219
+ var resolvePublicHubUrl = (current, detected) => {
37220
+ const detectedPublicHubUrl = detected[0] ?? "";
37221
+ if (current.length > 0) return {
37222
+ patch: { detectedPublicHubUrl },
37223
+ publicHubUrl: current,
37224
+ detectedPublicHubUrl,
37225
+ defaulted: false
37226
+ };
37227
+ if (detectedPublicHubUrl.length === 0) return {
37228
+ patch: { detectedPublicHubUrl },
37229
+ publicHubUrl: "",
37230
+ detectedPublicHubUrl,
37231
+ defaulted: false
37232
+ };
37233
+ return {
37234
+ patch: {
37235
+ publicHubUrl: detectedPublicHubUrl,
37236
+ detectedPublicHubUrl
37237
+ },
37238
+ publicHubUrl: detectedPublicHubUrl,
37239
+ detectedPublicHubUrl,
37240
+ defaulted: true
37241
+ };
37242
+ };
37243
+ /** Which of the two authored the value currently in the field. */
37244
+ var publicHubUrlSource = (state) => {
37245
+ if (state.publicHubUrl.length === 0) return "unset";
37246
+ if (state.detectedPublicHubUrl.length > 0 && state.publicHubUrl === state.detectedPublicHubUrl) return "derived";
37247
+ return "operator-set";
37248
+ };
37249
+ /**
37250
+ * The line under the field. A URL that appeared by itself with no provenance is
37251
+ * worse than a blank field: the operator cannot tell whether it is right.
37252
+ */
37253
+ var buildPublicHubUrlNotice = (state) => {
37254
+ switch (publicHubUrlSource(state)) {
37255
+ case "derived": return {
37256
+ variant: "info",
37257
+ 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.`
37258
+ };
37259
+ case "operator-set": return {
37260
+ variant: "info",
37261
+ 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.`
37262
+ };
37263
+ case "unset": return state.detectedPublicHubUrl.length > 0 ? {
37264
+ variant: "info",
37265
+ content: `Detected external address: ${state.detectedPublicHubUrl}. Press "Detect external address" to fill the field with it.`
37266
+ } : {
37267
+ variant: "warning",
37268
+ 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."
37269
+ };
37270
+ }
37271
+ };
37272
+ /**
37273
+ * The `device-export` setup block: the three console URLs plus the
37274
+ * linked-account count. All three come off the SAME origin, so they can never
37275
+ * disagree with each other.
37276
+ */
37277
+ var buildConsoleSetup = (input) => {
37278
+ const origin = normalisePublicHubUrl(input.publicHubUrl);
37279
+ const linked = String(input.linkedAccounts);
37280
+ if (origin.length === 0) return {
37281
+ note: ["Set the public hub URL on the settings form first — every URL Google needs is derived from it.", buildPublicHubUrlNotice(input).content].join("\n"),
37282
+ fields: [{
37283
+ label: "Linked Google accounts",
37284
+ value: linked
37285
+ }]
37286
+ };
37287
+ return {
37288
+ note: [
37289
+ "In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
37290
+ "The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
37291
+ "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.",
37292
+ buildPublicHubUrlNotice(input).content
37293
+ ].join("\n"),
37294
+ fields: [
37295
+ {
37296
+ label: "Fulfillment URL",
37297
+ value: `${origin}/addon/${input.addonId}/fulfillment`
37298
+ },
37299
+ {
37300
+ label: "Authorization URL",
37301
+ value: `${origin}/api/oauth2/authorize?integration=${input.addonId}`
37302
+ },
37303
+ {
37304
+ label: "Token URL",
37305
+ value: `${origin}/api/oauth2/token`
37306
+ },
37307
+ {
37308
+ label: "Linked Google accounts",
37309
+ value: linked
37310
+ }
37311
+ ]
37312
+ };
37313
+ };
37314
+ /**
37315
+ * Detect, apply the policy, persist, and say out loud what happened.
37316
+ *
37317
+ * Every branch here logs: a fill names the URL it picked and the source it came
37318
+ * from, and a miss that leaves the field empty says so rather than passing for
37319
+ * "never ran".
37320
+ */
37321
+ var refreshPublicHubUrl = async (deps) => {
37322
+ const detected = await deps.detect();
37323
+ const resolution = resolvePublicHubUrl(deps.current().publicHubUrl, detected);
37324
+ await deps.persist(resolution.patch);
37325
+ if (resolution.defaulted) deps.logger.info("export-google: defaulted public hub URL to the detected external address", { meta: {
37326
+ publicHubUrl: resolution.publicHubUrl,
37327
+ source: "network-access"
37328
+ } });
37329
+ 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 } });
37330
+ return resolution;
37331
+ };
37332
+ //#endregion
36974
37333
  //#region src/types.ts
36975
37334
  var DEFAULT_SETTINGS = {
36976
37335
  publicHubUrl: "",
37336
+ detectedPublicHubUrl: "",
36977
37337
  exposed: [],
36978
37338
  linkedAccounts: []
36979
37339
  };
@@ -37012,7 +37372,7 @@ var DEFAULT_SETTINGS = {
37012
37372
  *
37013
37373
  * Switches, dimmable lights, locks and covers — the non-camera fleet neither
37014
37374
  * `export-hap` nor `export-alexa` covers. Cameras are out of scope on purpose
37015
- * (docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md); the trait catalog is the single place that
37375
+ * (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
37376
  * decides, so widening scope is one row.
37017
37377
  */
37018
37378
  var ADDON_ID = "export-google";
@@ -37027,6 +37387,9 @@ var ExportGoogleAddon = class extends BaseAddon {
37027
37387
  logger: this.ctx.logger
37028
37388
  });
37029
37389
  this.gateway = gateway;
37390
+ this.detectPublicHubUrl().catch((err) => {
37391
+ this.ctx.logger.warn("export-google: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
37392
+ });
37030
37393
  const deviceExportProvider = {
37031
37394
  getStatus: async () => ({
37032
37395
  linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
@@ -37066,26 +37429,93 @@ var ExportGoogleAddon = class extends BaseAddon {
37066
37429
  }
37067
37430
  }));
37068
37431
  const oauthIntegrationProvider = { getDescriptor: async () => buildGoogleOauthIntegration() };
37432
+ const offTunnelStarted = this.ctx.eventBus.subscribe({ category: EventCategory.NetworkTunnelStarted }, () => {
37433
+ this.detectPublicHubUrl().catch((err) => {
37434
+ this.ctx.logger.warn("export-google: public hub URL detect after tunnel connect failed", { meta: { error: errMsg(err) } });
37435
+ });
37436
+ });
37437
+ this.ctx.addDisposer(async () => offTunnelStarted());
37069
37438
  this.ctx.logger.info("export-google: initialized", { meta: {
37070
37439
  exposedCount: this.config.exposed.length,
37071
37440
  linkedAccounts: this.config.linkedAccounts.length,
37072
37441
  publicHubUrlSet: this.config.publicHubUrl.length > 0,
37073
37442
  willReportState: false
37074
37443
  } });
37075
- return { providers: [
37076
- {
37077
- capability: deviceExportCapability,
37078
- provider: deviceExportProvider
37079
- },
37080
- {
37081
- capability: addonRoutesCapability,
37082
- provider: routeProvider
37444
+ return {
37445
+ providers: [
37446
+ {
37447
+ capability: deviceExportCapability,
37448
+ provider: deviceExportProvider
37449
+ },
37450
+ {
37451
+ capability: addonRoutesCapability,
37452
+ provider: routeProvider
37453
+ },
37454
+ {
37455
+ capability: oauthIntegrationCapability,
37456
+ provider: oauthIntegrationProvider
37457
+ }
37458
+ ],
37459
+ customActions: exportGoogleActions,
37460
+ actionHandlers: { detectPublicHubUrl: async () => this.detectPublicHubUrl() }
37461
+ };
37462
+ }
37463
+ /** This addon's slice of `PublicHubUrlState`. */
37464
+ publicHubUrlState() {
37465
+ return {
37466
+ publicHubUrl: this.config.publicHubUrl,
37467
+ detectedPublicHubUrl: this.config.detectedPublicHubUrl
37468
+ };
37469
+ }
37470
+ /**
37471
+ * Adapter from the codegen'd `network-access` router onto the narrow reader
37472
+ * `public-hub-url.ts` consumes. Fields are copied one by one rather than
37473
+ * passed through, so the module never depends on the cap's wider shape and a
37474
+ * test double for it needs no cast.
37475
+ */
37476
+ networkAccessReader() {
37477
+ return {
37478
+ listEndpoints: async () => {
37479
+ return (await this.ctx.api.networkAccess.listEndpoints.query({})).map((entry) => ({
37480
+ url: entry.url,
37481
+ protocol: entry.protocol
37482
+ }));
37083
37483
  },
37084
- {
37085
- capability: oauthIntegrationCapability,
37086
- provider: oauthIntegrationProvider
37484
+ getStatus: async () => {
37485
+ const status = await this.ctx.api.networkAccess.getStatus.query({});
37486
+ return {
37487
+ connected: status.connected,
37488
+ endpoint: status.endpoint === null ? null : {
37489
+ url: status.endpoint.url,
37490
+ protocol: status.endpoint.protocol
37491
+ }
37492
+ };
37087
37493
  }
37088
- ] };
37494
+ };
37495
+ }
37496
+ /**
37497
+ * Refresh the detected address and DEFAULT the field to it when it is empty.
37498
+ * Backs both the boot-time detect and the "Detect external address" button.
37499
+ *
37500
+ * Never replaces a value the operator set — that value is what they already
37501
+ * pasted into the Google console, and rewriting it here would leave the two
37502
+ * sides disagreeing with nothing on screen to say so.
37503
+ */
37504
+ async detectPublicHubUrl() {
37505
+ const resolution = await refreshPublicHubUrl({
37506
+ current: () => this.publicHubUrlState(),
37507
+ detect: () => detectPublicHubOrigins({
37508
+ network: this.networkAccessReader(),
37509
+ logger: this.ctx.logger
37510
+ }),
37511
+ persist: (patch) => this.updateGlobalSettings(patch),
37512
+ logger: this.ctx.logger
37513
+ });
37514
+ return {
37515
+ publicHubUrl: resolution.publicHubUrl,
37516
+ detectedPublicHubUrl: resolution.detectedPublicHubUrl,
37517
+ filled: resolution.defaulted
37518
+ };
37089
37519
  }
37090
37520
  async onShutdown() {
37091
37521
  this.gateway = null;
@@ -37155,65 +37585,54 @@ var ExportGoogleAddon = class extends BaseAddon {
37155
37585
  * tool for a real credential anyway (docs/decisions/adr-0269-a-google-export-that-holds-no-google-credential.md).
37156
37586
  */
37157
37587
  buildSetupBlock() {
37158
- const origin = this.config.publicHubUrl.replace(/\/+$/, "");
37159
- const linked = this.config.linkedAccounts.length;
37160
- if (origin.length === 0) return {
37161
- note: "Set the public hub URL on the settings form first — every URL Google needs is derived from it.",
37162
- fields: [{
37163
- label: "Linked Google accounts",
37164
- value: String(linked)
37165
- }]
37166
- };
37167
- return {
37168
- note: [
37169
- "In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
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"),
37588
+ return buildConsoleSetup({
37589
+ addonId: ADDON_ID,
37590
+ ...this.publicHubUrlState(),
37591
+ linkedAccounts: this.config.linkedAccounts.length
37592
+ });
37593
+ }
37594
+ globalSettingsSchema() {
37595
+ return this.schema({ sections: [{
37596
+ id: ADDON_ID,
37597
+ title: "Google Home export",
37598
+ 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.",
37599
+ columns: 1,
37173
37600
  fields: [
37174
37601
  {
37175
- label: "Fulfillment URL",
37176
- value: `${origin}/addon/${ADDON_ID}/fulfillment`
37177
- },
37178
- {
37179
- label: "Authorization URL",
37180
- value: `${origin}/api/oauth2/authorize?integration=${ADDON_ID}`
37602
+ type: "info",
37603
+ key: "__google-setup-banner",
37604
+ label: "Setup overview",
37605
+ variant: "info",
37606
+ content: [
37607
+ "This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
37608
+ "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.",
37609
+ "Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
37610
+ ].join("\n")
37181
37611
  },
37612
+ this.field({
37613
+ type: "text",
37614
+ key: "publicHubUrl",
37615
+ label: "Public hub URL",
37616
+ 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.",
37617
+ placeholder: "https://hub.example.com",
37618
+ default: DEFAULT_SETTINGS.publicHubUrl
37619
+ }),
37182
37620
  {
37183
- label: "Token URL",
37184
- value: `${origin}/api/oauth2/token`
37621
+ type: "info",
37622
+ key: "__public-hub-url-source",
37623
+ label: "Where this came from",
37624
+ ...buildPublicHubUrlNotice(this.publicHubUrlState())
37185
37625
  },
37186
37626
  {
37187
- label: "Linked Google accounts",
37188
- value: String(linked)
37627
+ type: "button",
37628
+ key: "__detect-public-hub-url",
37629
+ label: "External address",
37630
+ 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.",
37631
+ buttonLabel: "Detect external address",
37632
+ action: "detectPublicHubUrl",
37633
+ variant: "default"
37189
37634
  }
37190
37635
  ]
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
37636
  }] });
37218
37637
  }
37219
37638
  /**
@@ -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
@@ -13858,6 +13880,50 @@ var NodeProcessSchema = object({
13858
13880
  /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
13859
13881
  uptimeSec: number()
13860
13882
  });
13883
+ /**
13884
+ * One retained container-memory reading.
13885
+ *
13886
+ * `atMs` is the timestamp of the PROCESS snapshot taken in the same tick, not
13887
+ * a second clock: that is what makes "processes sum to X, container says Y"
13888
+ * subtractable per point rather than an eyeballed comparison of two series
13889
+ * sampled at different instants.
13890
+ *
13891
+ * A reduced window keeps the sample with the LARGEST `currentBytes` in each
13892
+ * bucket, WHOLE. Taking a per-field maximum would synthesise a row whose parts
13893
+ * never coexisted, and a mean would smear away the peak this exists to find.
13894
+ */
13895
+ var ContainerMemoryPointSchema = object({
13896
+ /** Which hierarchy answered, so a reading is never ambiguous. */
13897
+ source: _enum(["cgroup-v2", "cgroup-v1"]),
13898
+ /** `memory.current` (v2) / `memory.usage_in_bytes` (v1). Always known. */
13899
+ currentBytes: number(),
13900
+ /** The cgroup's ceiling. `null` = NO LIMIT — never a sentinel, never zero. */
13901
+ limitBytes: number().nullable(),
13902
+ /** Anonymous pages: the closest thing to "what the processes allocated". */
13903
+ anonBytes: number().nullable(),
13904
+ /** Page cache. Charged to the cgroup, owned by no process. */
13905
+ fileBytes: number().nullable(),
13906
+ /**
13907
+ * Shared memory — and the field that explained the largest single surprise.
13908
+ * The i915 driver backs GPU buffers with shmem, so an inference pool or a
13909
+ * hardware-decode session holding DRM objects is charged HERE and appears
13910
+ * nowhere in a `ps` scan.
13911
+ */
13912
+ shmemBytes: number().nullable(),
13913
+ /** Kernel slab charged to this cgroup. `null` on v1, which never publishes it. */
13914
+ slabBytes: number().nullable(),
13915
+ /**
13916
+ * Shrinkable i915 GEM object bytes, from debugfs.
13917
+ *
13918
+ * **Host-wide across every DRM client, NOT cgroup-scoped.** It is not a
13919
+ * component of `currentBytes` and must not be subtracted from it; it says
13920
+ * what put the shmem there, where `shmemBytes` only says how much.
13921
+ *
13922
+ * `null` wherever debugfs is not mounted — which is inside every camstack
13923
+ * container today — and on any node with no Intel GPU.
13924
+ */
13925
+ gpuShmemBytes: number().nullable()
13926
+ }).extend({ atMs: number() });
13861
13927
  var DumpHeapSnapshotInputSchema = object({
13862
13928
  /** The addon whose runner should dump a heap snapshot. */
13863
13929
  addonId: string() });
@@ -13921,6 +13987,21 @@ var NodeLoadSeriesSchema = object({
13921
13987
  /** One entry per function seen in the window, heaviest-first. */
13922
13988
  series: array(LoadFunctionSeriesSchema).readonly(),
13923
13989
  /**
13990
+ * The CONTAINER's memory over the same window, oldest-first.
13991
+ *
13992
+ * Sits next to `series` rather than in a method of its own because the whole
13993
+ * question is a subtraction: the per-process rows in `series` sum to one
13994
+ * number and this one is another, and an operator who has to issue two calls
13995
+ * to compare them will compare two different instants. Same reader, same
13996
+ * `sinceMs`, same `bucketMs`, same timestamps.
13997
+ *
13998
+ * **EMPTY means ABSENT, never zero.** A node with no cgroup — a developer
13999
+ * Mac, a bare-metal host, a container with the hierarchy hidden — reports no
14000
+ * points at all. A zero here would be indistinguishable from a healthy
14001
+ * container and is precisely the lie this field exists to avoid.
14002
+ */
14003
+ containerMemory: array(ContainerMemoryPointSchema).readonly(),
14004
+ /**
13924
14005
  * Width of one returned bucket, in ms. Equals the sampling cadence when no
13925
14006
  * reduction was needed — so a caller can always say what one point covers
13926
14007
  * without having to know whether it was reduced.
@@ -25878,10 +25959,10 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceI
25878
25959
  * recording config. NOTE on events (source of truth, R5/C3): this cap carries
25879
25960
  * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
25880
25961
  * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
25881
- * rows); the recorder's internal EventMap markers are ephemeral in-RAM
25882
- * annotations that are not exposed here and must not be treated as an event
25883
- * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
25884
- * (`interfaces/recording-config.ts`).
25962
+ * rows) and are the ONLY event surface the recorder has none. The in-RAM
25963
+ * playback markers it used to build were deleted on 2026-08-29 because nothing
25964
+ * ever read them. Event<->footage joins are by time, padded with the shared
25965
+ * `EVENT_PAD_MS` (`interfaces/recording-config.ts`).
25885
25966
  */
25886
25967
  var RecordingStatusSchema = object({
25887
25968
  deviceId: number(),
@@ -36190,6 +36271,32 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
36190
36271
  var MB = 1024 * 1024;
36191
36272
  1024 * MB, 3072 * MB;
36192
36273
  //#endregion
36274
+ //#region src/custom-actions.ts
36275
+ /**
36276
+ * Google Home export — customActions catalog.
36277
+ *
36278
+ * One entry. The settings form's `type: 'button'` field dispatches through the
36279
+ * generic `api.addons.custom.mutate({ addonId, action, input })` channel, so a
36280
+ * button here costs no capability, no codegen and no framework publish train —
36281
+ * the catalog travels inside this addon's own bundle.
36282
+ */
36283
+ var exportGoogleActions = defineCustomActions({
36284
+ /**
36285
+ * Re-scan the connected `network-access` providers and refresh the address
36286
+ * shown under the field.
36287
+ *
36288
+ * Deliberately NOT the same contract as `export-alexa`'s action of the same
36289
+ * shape: that one replaces a manual override when it is no longer detected,
36290
+ * because Alexa ROUTES on the value. Nothing routes on this one — it is what
36291
+ * the operator already pasted into Google's console — so this only fills the
36292
+ * field when it is empty. `filled` says whether it did.
36293
+ */
36294
+ detectPublicHubUrl: customAction(object({}).optional(), object({
36295
+ publicHubUrl: string(),
36296
+ detectedPublicHubUrl: string(),
36297
+ filled: boolean()
36298
+ }), { kind: "mutation" }) });
36299
+ //#endregion
36193
36300
  //#region src/google-home/trait-catalog.ts
36194
36301
  /**
36195
36302
  * The trait catalog — one ROW per camstack capability, and that row is the
@@ -36218,7 +36325,7 @@ var MB = 1024 * 1024;
36218
36325
  * Cameras. Google's `CameraStream` trait answers `GetCameraStream` with a
36219
36326
  * playable URL or a WebRTC signaling endpoint, and neither exists here yet, so
36220
36327
  * a camera would be declared and then fail every command. It is omitted rather
36221
- * than declared — see docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36328
+ * than declared — see docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36222
36329
  */
36223
36330
  /**
36224
36331
  * The Google state a SUCCEEDED call establishes.
@@ -36967,9 +37074,262 @@ function buildGoogleOauthIntegration() {
36967
37074
  };
36968
37075
  }
36969
37076
  //#endregion
37077
+ //#region src/public-hub-url.ts
37078
+ /**
37079
+ * The hub's public HTTPS origin — detected, never invented.
37080
+ *
37081
+ * `publicHubUrl` is the single operator-facing value in this addon, and the
37082
+ * three URLs pasted into the Google Home Developer Console (fulfillment,
37083
+ * authorization, token) are all rendered from it. Nothing ROUTES on it: Google
37084
+ * calls the fulfillment URL it was configured with. A wrong value therefore
37085
+ * fails at paste time, in the console, hours before anything here notices —
37086
+ * which is exactly why the field has to say where its content came from.
37087
+ *
37088
+ * ## Where the URL comes from
37089
+ *
37090
+ * The `network-access` capability, same source `addon-export-alexa` uses. It is
37091
+ * a codegen'd collection cap with `scope: 'system'`, so it is reachable
37092
+ * cross-process through `ctx.api` — unlike the `addons` core router, which a
37093
+ * forked addon calling `ctx.api.addons.*` would wait on forever.
37094
+ *
37095
+ * **Not** `CAMSTACK_HUB_PUBLIC_URL`. That variable answers a different
37096
+ * question: which origin the hub should mint its OWN media / model-distribution
37097
+ * links on, and it defaults to `https://127.0.0.1:4443`. It is routinely a
37098
+ * loopback or LAN address, and the backend's `publicHubUrl()` fallback is
37099
+ * localhost in dev. Google calls from its own cloud, so a LAN answer here is
37100
+ * worse than no answer — it produces three console URLs that look complete and
37101
+ * can never be reached.
37102
+ *
37103
+ * ## Why this is a second copy of Alexa's mechanism
37104
+ *
37105
+ * Alexa's lives in `packages/addon-export-alexa/src/export-alexa.addon.ts` as
37106
+ * private methods, and addons never import each other. The only shared home
37107
+ * would be `@camstack/types` or `@camstack/system` — both stay host-resolved in
37108
+ * an addon bundle (`tools/build/vite-lib.preset.ts`), so putting it there would
37109
+ * put this addon on the framework publish train and destroy the one property
37110
+ * that makes it attractive: `camstack deploy packages/addon-export-google` and
37111
+ * nothing else. The SOURCE and the POLICY are Alexa's; two behaviours diverge
37112
+ * on purpose and both are named below.
37113
+ *
37114
+ * ## Two deliberate divergences from Alexa
37115
+ *
37116
+ * 1. **Refresh never clobbers.** Alexa's `redetectPublicHubUrl` replaces a
37117
+ * manual override whenever it is not among the detected endpoints — sound
37118
+ * there, because the URL is baked into every Alexa-bound JWT as a routing
37119
+ * claim and a stale one breaks routing. Here the value is what the operator
37120
+ * already pasted into Google's console; silently rewriting it would leave
37121
+ * the console and the hub disagreeing with no visible cause. Detection fills
37122
+ * an EMPTY field and does nothing else.
37123
+ * 2. **A non-routable origin is dropped.** Alexa forwards whatever the cap
37124
+ * reports. See {@link isPubliclyRoutableOrigin}.
37125
+ */
37126
+ /** Trailing slashes off, surrounding space off. Nothing else is rewritten. */
37127
+ var normalisePublicHubUrl = (raw) => raw.trim().replace(/\/+$/, "");
37128
+ var LOOPBACK_HOSTS = [
37129
+ "localhost",
37130
+ "127.0.0.1",
37131
+ "::1",
37132
+ "[::1]",
37133
+ "0.0.0.0"
37134
+ ];
37135
+ /**
37136
+ * Whether Google's cloud could plausibly reach this origin.
37137
+ *
37138
+ * The `network-access` providers this hub ships (Cloudflare Tunnel, Tailscale
37139
+ * Funnel) publish public FQDNs, but the cap does not promise it: a provider is
37140
+ * free to report an HTTPS ingress on a private address. Defaulting the field to
37141
+ * one of those would hand the operator three console URLs that look finished
37142
+ * and can never answer — strictly worse than an empty field, because an empty
37143
+ * field is visibly unfinished.
37144
+ *
37145
+ * Conservative on purpose: only loopback, RFC1918, link-local, `.local` and
37146
+ * dotless hostnames are refused. A public tunnel hostname always has a dot.
37147
+ */
37148
+ var isPubliclyRoutableOrigin = (origin) => {
37149
+ let host;
37150
+ try {
37151
+ host = new URL(origin).hostname.toLowerCase();
37152
+ } catch {
37153
+ return false;
37154
+ }
37155
+ if (host.length === 0) return false;
37156
+ if (LOOPBACK_HOSTS.includes(host)) return false;
37157
+ if (host.startsWith("127.") || host.startsWith("169.254.")) return false;
37158
+ if (host.startsWith("10.") || host.startsWith("192.168.")) return false;
37159
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
37160
+ if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".lan")) return false;
37161
+ if (!host.includes(".")) return false;
37162
+ return true;
37163
+ };
37164
+ /**
37165
+ * Every publicly routable HTTPS origin the `network-access` cap reports, in the
37166
+ * cap's own order, deduped. Empty when there is none — the caller leaves the
37167
+ * field alone rather than inventing a value.
37168
+ */
37169
+ var detectPublicHubOrigins = async (deps) => {
37170
+ const fromList = await originsFromListEndpoints(deps);
37171
+ if (fromList.length > 0) return fromList;
37172
+ const fromStatus = await originFromGetStatus(deps);
37173
+ if (fromStatus.length > 0) return [fromStatus];
37174
+ 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.");
37175
+ return [];
37176
+ };
37177
+ var usableOrigins = (candidates, logger) => {
37178
+ const out = /* @__PURE__ */ new Set();
37179
+ for (const candidate of candidates) {
37180
+ if (candidate.protocol !== "https" || candidate.url.length === 0) continue;
37181
+ const origin = normalisePublicHubUrl(candidate.url);
37182
+ if (!isPubliclyRoutableOrigin(origin)) {
37183
+ 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 } });
37184
+ continue;
37185
+ }
37186
+ out.add(origin);
37187
+ }
37188
+ return [...out];
37189
+ };
37190
+ var originsFromListEndpoints = async (deps) => {
37191
+ try {
37192
+ return usableOrigins(await deps.network.listEndpoints(), deps.logger);
37193
+ } catch (err) {
37194
+ deps.logger.debug("export-google: networkAccess.listEndpoints failed", { meta: { error: errMsg(err) } });
37195
+ return [];
37196
+ }
37197
+ };
37198
+ var originFromGetStatus = async (deps) => {
37199
+ try {
37200
+ const status = await deps.network.getStatus();
37201
+ if (status === null || !status.connected || status.endpoint === null) return "";
37202
+ return usableOrigins([status.endpoint], deps.logger)[0] ?? "";
37203
+ } catch (err) {
37204
+ deps.logger.debug("export-google: networkAccess.getStatus failed", { meta: { error: errMsg(err) } });
37205
+ return "";
37206
+ }
37207
+ };
37208
+ /**
37209
+ * Apply the default-not-override policy.
37210
+ *
37211
+ * The operator's value is never in the returned patch. Detection's own answer
37212
+ * always is, because the settings panel needs it to say "you set this one, and
37213
+ * a different one is detected".
37214
+ */
37215
+ var resolvePublicHubUrl = (current, detected) => {
37216
+ const detectedPublicHubUrl = detected[0] ?? "";
37217
+ if (current.length > 0) return {
37218
+ patch: { detectedPublicHubUrl },
37219
+ publicHubUrl: current,
37220
+ detectedPublicHubUrl,
37221
+ defaulted: false
37222
+ };
37223
+ if (detectedPublicHubUrl.length === 0) return {
37224
+ patch: { detectedPublicHubUrl },
37225
+ publicHubUrl: "",
37226
+ detectedPublicHubUrl,
37227
+ defaulted: false
37228
+ };
37229
+ return {
37230
+ patch: {
37231
+ publicHubUrl: detectedPublicHubUrl,
37232
+ detectedPublicHubUrl
37233
+ },
37234
+ publicHubUrl: detectedPublicHubUrl,
37235
+ detectedPublicHubUrl,
37236
+ defaulted: true
37237
+ };
37238
+ };
37239
+ /** Which of the two authored the value currently in the field. */
37240
+ var publicHubUrlSource = (state) => {
37241
+ if (state.publicHubUrl.length === 0) return "unset";
37242
+ if (state.detectedPublicHubUrl.length > 0 && state.publicHubUrl === state.detectedPublicHubUrl) return "derived";
37243
+ return "operator-set";
37244
+ };
37245
+ /**
37246
+ * The line under the field. A URL that appeared by itself with no provenance is
37247
+ * worse than a blank field: the operator cannot tell whether it is right.
37248
+ */
37249
+ var buildPublicHubUrlNotice = (state) => {
37250
+ switch (publicHubUrlSource(state)) {
37251
+ case "derived": return {
37252
+ variant: "info",
37253
+ 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.`
37254
+ };
37255
+ case "operator-set": return {
37256
+ variant: "info",
37257
+ 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.`
37258
+ };
37259
+ case "unset": return state.detectedPublicHubUrl.length > 0 ? {
37260
+ variant: "info",
37261
+ content: `Detected external address: ${state.detectedPublicHubUrl}. Press "Detect external address" to fill the field with it.`
37262
+ } : {
37263
+ variant: "warning",
37264
+ 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."
37265
+ };
37266
+ }
37267
+ };
37268
+ /**
37269
+ * The `device-export` setup block: the three console URLs plus the
37270
+ * linked-account count. All three come off the SAME origin, so they can never
37271
+ * disagree with each other.
37272
+ */
37273
+ var buildConsoleSetup = (input) => {
37274
+ const origin = normalisePublicHubUrl(input.publicHubUrl);
37275
+ const linked = String(input.linkedAccounts);
37276
+ if (origin.length === 0) return {
37277
+ note: ["Set the public hub URL on the settings form first — every URL Google needs is derived from it.", buildPublicHubUrlNotice(input).content].join("\n"),
37278
+ fields: [{
37279
+ label: "Linked Google accounts",
37280
+ value: linked
37281
+ }]
37282
+ };
37283
+ return {
37284
+ note: [
37285
+ "In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
37286
+ "The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
37287
+ "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.",
37288
+ buildPublicHubUrlNotice(input).content
37289
+ ].join("\n"),
37290
+ fields: [
37291
+ {
37292
+ label: "Fulfillment URL",
37293
+ value: `${origin}/addon/${input.addonId}/fulfillment`
37294
+ },
37295
+ {
37296
+ label: "Authorization URL",
37297
+ value: `${origin}/api/oauth2/authorize?integration=${input.addonId}`
37298
+ },
37299
+ {
37300
+ label: "Token URL",
37301
+ value: `${origin}/api/oauth2/token`
37302
+ },
37303
+ {
37304
+ label: "Linked Google accounts",
37305
+ value: linked
37306
+ }
37307
+ ]
37308
+ };
37309
+ };
37310
+ /**
37311
+ * Detect, apply the policy, persist, and say out loud what happened.
37312
+ *
37313
+ * Every branch here logs: a fill names the URL it picked and the source it came
37314
+ * from, and a miss that leaves the field empty says so rather than passing for
37315
+ * "never ran".
37316
+ */
37317
+ var refreshPublicHubUrl = async (deps) => {
37318
+ const detected = await deps.detect();
37319
+ const resolution = resolvePublicHubUrl(deps.current().publicHubUrl, detected);
37320
+ await deps.persist(resolution.patch);
37321
+ if (resolution.defaulted) deps.logger.info("export-google: defaulted public hub URL to the detected external address", { meta: {
37322
+ publicHubUrl: resolution.publicHubUrl,
37323
+ source: "network-access"
37324
+ } });
37325
+ 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 } });
37326
+ return resolution;
37327
+ };
37328
+ //#endregion
36970
37329
  //#region src/types.ts
36971
37330
  var DEFAULT_SETTINGS = {
36972
37331
  publicHubUrl: "",
37332
+ detectedPublicHubUrl: "",
36973
37333
  exposed: [],
36974
37334
  linkedAccounts: []
36975
37335
  };
@@ -37008,7 +37368,7 @@ var DEFAULT_SETTINGS = {
37008
37368
  *
37009
37369
  * Switches, dimmable lights, locks and covers — the non-camera fleet neither
37010
37370
  * `export-hap` nor `export-alexa` covers. Cameras are out of scope on purpose
37011
- * (docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md); the trait catalog is the single place that
37371
+ * (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
37372
  * decides, so widening scope is one row.
37013
37373
  */
37014
37374
  var ADDON_ID = "export-google";
@@ -37023,6 +37383,9 @@ var ExportGoogleAddon = class extends BaseAddon {
37023
37383
  logger: this.ctx.logger
37024
37384
  });
37025
37385
  this.gateway = gateway;
37386
+ this.detectPublicHubUrl().catch((err) => {
37387
+ this.ctx.logger.warn("export-google: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
37388
+ });
37026
37389
  const deviceExportProvider = {
37027
37390
  getStatus: async () => ({
37028
37391
  linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
@@ -37062,26 +37425,93 @@ var ExportGoogleAddon = class extends BaseAddon {
37062
37425
  }
37063
37426
  }));
37064
37427
  const oauthIntegrationProvider = { getDescriptor: async () => buildGoogleOauthIntegration() };
37428
+ const offTunnelStarted = this.ctx.eventBus.subscribe({ category: EventCategory.NetworkTunnelStarted }, () => {
37429
+ this.detectPublicHubUrl().catch((err) => {
37430
+ this.ctx.logger.warn("export-google: public hub URL detect after tunnel connect failed", { meta: { error: errMsg(err) } });
37431
+ });
37432
+ });
37433
+ this.ctx.addDisposer(async () => offTunnelStarted());
37065
37434
  this.ctx.logger.info("export-google: initialized", { meta: {
37066
37435
  exposedCount: this.config.exposed.length,
37067
37436
  linkedAccounts: this.config.linkedAccounts.length,
37068
37437
  publicHubUrlSet: this.config.publicHubUrl.length > 0,
37069
37438
  willReportState: false
37070
37439
  } });
37071
- return { providers: [
37072
- {
37073
- capability: deviceExportCapability,
37074
- provider: deviceExportProvider
37075
- },
37076
- {
37077
- capability: addonRoutesCapability,
37078
- provider: routeProvider
37440
+ return {
37441
+ providers: [
37442
+ {
37443
+ capability: deviceExportCapability,
37444
+ provider: deviceExportProvider
37445
+ },
37446
+ {
37447
+ capability: addonRoutesCapability,
37448
+ provider: routeProvider
37449
+ },
37450
+ {
37451
+ capability: oauthIntegrationCapability,
37452
+ provider: oauthIntegrationProvider
37453
+ }
37454
+ ],
37455
+ customActions: exportGoogleActions,
37456
+ actionHandlers: { detectPublicHubUrl: async () => this.detectPublicHubUrl() }
37457
+ };
37458
+ }
37459
+ /** This addon's slice of `PublicHubUrlState`. */
37460
+ publicHubUrlState() {
37461
+ return {
37462
+ publicHubUrl: this.config.publicHubUrl,
37463
+ detectedPublicHubUrl: this.config.detectedPublicHubUrl
37464
+ };
37465
+ }
37466
+ /**
37467
+ * Adapter from the codegen'd `network-access` router onto the narrow reader
37468
+ * `public-hub-url.ts` consumes. Fields are copied one by one rather than
37469
+ * passed through, so the module never depends on the cap's wider shape and a
37470
+ * test double for it needs no cast.
37471
+ */
37472
+ networkAccessReader() {
37473
+ return {
37474
+ listEndpoints: async () => {
37475
+ return (await this.ctx.api.networkAccess.listEndpoints.query({})).map((entry) => ({
37476
+ url: entry.url,
37477
+ protocol: entry.protocol
37478
+ }));
37079
37479
  },
37080
- {
37081
- capability: oauthIntegrationCapability,
37082
- provider: oauthIntegrationProvider
37480
+ getStatus: async () => {
37481
+ const status = await this.ctx.api.networkAccess.getStatus.query({});
37482
+ return {
37483
+ connected: status.connected,
37484
+ endpoint: status.endpoint === null ? null : {
37485
+ url: status.endpoint.url,
37486
+ protocol: status.endpoint.protocol
37487
+ }
37488
+ };
37083
37489
  }
37084
- ] };
37490
+ };
37491
+ }
37492
+ /**
37493
+ * Refresh the detected address and DEFAULT the field to it when it is empty.
37494
+ * Backs both the boot-time detect and the "Detect external address" button.
37495
+ *
37496
+ * Never replaces a value the operator set — that value is what they already
37497
+ * pasted into the Google console, and rewriting it here would leave the two
37498
+ * sides disagreeing with nothing on screen to say so.
37499
+ */
37500
+ async detectPublicHubUrl() {
37501
+ const resolution = await refreshPublicHubUrl({
37502
+ current: () => this.publicHubUrlState(),
37503
+ detect: () => detectPublicHubOrigins({
37504
+ network: this.networkAccessReader(),
37505
+ logger: this.ctx.logger
37506
+ }),
37507
+ persist: (patch) => this.updateGlobalSettings(patch),
37508
+ logger: this.ctx.logger
37509
+ });
37510
+ return {
37511
+ publicHubUrl: resolution.publicHubUrl,
37512
+ detectedPublicHubUrl: resolution.detectedPublicHubUrl,
37513
+ filled: resolution.defaulted
37514
+ };
37085
37515
  }
37086
37516
  async onShutdown() {
37087
37517
  this.gateway = null;
@@ -37151,65 +37581,54 @@ var ExportGoogleAddon = class extends BaseAddon {
37151
37581
  * tool for a real credential anyway (docs/decisions/adr-0269-a-google-export-that-holds-no-google-credential.md).
37152
37582
  */
37153
37583
  buildSetupBlock() {
37154
- const origin = this.config.publicHubUrl.replace(/\/+$/, "");
37155
- const linked = this.config.linkedAccounts.length;
37156
- if (origin.length === 0) return {
37157
- note: "Set the public hub URL on the settings form first — every URL Google needs is derived from it.",
37158
- fields: [{
37159
- label: "Linked Google accounts",
37160
- value: String(linked)
37161
- }]
37162
- };
37163
- return {
37164
- note: [
37165
- "In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
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"),
37584
+ return buildConsoleSetup({
37585
+ addonId: ADDON_ID,
37586
+ ...this.publicHubUrlState(),
37587
+ linkedAccounts: this.config.linkedAccounts.length
37588
+ });
37589
+ }
37590
+ globalSettingsSchema() {
37591
+ return this.schema({ sections: [{
37592
+ id: ADDON_ID,
37593
+ title: "Google Home export",
37594
+ 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.",
37595
+ columns: 1,
37169
37596
  fields: [
37170
37597
  {
37171
- label: "Fulfillment URL",
37172
- value: `${origin}/addon/${ADDON_ID}/fulfillment`
37173
- },
37174
- {
37175
- label: "Authorization URL",
37176
- value: `${origin}/api/oauth2/authorize?integration=${ADDON_ID}`
37598
+ type: "info",
37599
+ key: "__google-setup-banner",
37600
+ label: "Setup overview",
37601
+ variant: "info",
37602
+ content: [
37603
+ "This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
37604
+ "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.",
37605
+ "Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
37606
+ ].join("\n")
37177
37607
  },
37608
+ this.field({
37609
+ type: "text",
37610
+ key: "publicHubUrl",
37611
+ label: "Public hub URL",
37612
+ 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.",
37613
+ placeholder: "https://hub.example.com",
37614
+ default: DEFAULT_SETTINGS.publicHubUrl
37615
+ }),
37178
37616
  {
37179
- label: "Token URL",
37180
- value: `${origin}/api/oauth2/token`
37617
+ type: "info",
37618
+ key: "__public-hub-url-source",
37619
+ label: "Where this came from",
37620
+ ...buildPublicHubUrlNotice(this.publicHubUrlState())
37181
37621
  },
37182
37622
  {
37183
- label: "Linked Google accounts",
37184
- value: String(linked)
37623
+ type: "button",
37624
+ key: "__detect-public-hub-url",
37625
+ label: "External address",
37626
+ 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.",
37627
+ buttonLabel: "Detect external address",
37628
+ action: "detectPublicHubUrl",
37629
+ variant: "default"
37185
37630
  }
37186
37631
  ]
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
37632
  }] });
37214
37633
  }
37215
37634
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-google",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
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",