@camstack/addon-export-google 0.1.1 → 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.
@@ -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
@@ -13443,6 +13465,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13443
13465
  limit: number().optional(),
13444
13466
  tags: record(string(), string()).optional()
13445
13467
  }), array(LogEntrySchema).readonly());
13468
+ /**
13469
+ * `failure-contribution` — the capability an addon reports its OWN losses
13470
+ * through, per camera, with the denominator attached. It stores nothing.
13471
+ *
13472
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13473
+ *
13474
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13475
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13476
+ * copied: the contributor reports what it already knows, hub-main adds only
13477
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13478
+ * somebody to forget to edit.
13479
+ *
13480
+ * They are not merged, because their invariants are opposites:
13481
+ *
13482
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13483
+ * claim a camera cost nothing, which is a measurement nobody made;
13484
+ * - a `failure-contribution` zero is the **most valuable value on the
13485
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13486
+ * and it is exactly what an absent entry cannot say.
13487
+ *
13488
+ * Putting a loss counter on a cost entry would also break the reconciliation
13489
+ * that gives `load-contribution` its point: contributions are subtracted from
13490
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13491
+ * has no process.
13492
+ *
13493
+ * ## Why not a log line, since the counters already exist
13494
+ *
13495
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13496
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13497
+ * ends in a log line, and a log line is the thing the operator asked to stop
13498
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13499
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13500
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13501
+ * media blackout were both diagnosed. The counters stay; this is where they can
13502
+ * be READ.
13503
+ *
13504
+ * ## The rate is served with its denominator or not at all
13505
+ *
13506
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13507
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13508
+ * than yesterday" and was **flat across twelve hours** once divided by the
13509
+ * successes on the same path. A surface that publishes only the numerator
13510
+ * reproduces that mistake on every read.
13511
+ *
13512
+ * ## Shape
13513
+ *
13514
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13515
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13516
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13517
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13518
+ * a forked runner's entries reach hub-main over transport that already exists.
13519
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13520
+ * result through `system.getFailureContributions`.
13521
+ */
13522
+ var FailureReasonCountSchema = object({
13523
+ /**
13524
+ * Why the attempt did not land, in the contributor's own vocabulary —
13525
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13526
+ * strings that already appear in this repo's logs and, where one exists, the
13527
+ * same string the per-track `previewMissReason` records (D276): a second
13528
+ * vocabulary for the same loss would make the row and the counter
13529
+ * un-joinable.
13530
+ */
13531
+ reason: string(),
13532
+ count: number().int().nonnegative()
13533
+ });
13534
+ var FailureContributionSchema = object({
13535
+ /**
13536
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13537
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13538
+ * `unit` free: the families are owned by different addons and a shared enum
13539
+ * is a central list that rots invisibly.
13540
+ */
13541
+ family: string(),
13542
+ /**
13543
+ * The NUMERIC device id — the same value every log line carries as
13544
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13545
+ * cannot name the camera must not emit the entry, because a fleet total
13546
+ * cannot answer the only question anybody asks of this surface.
13547
+ */
13548
+ deviceId: number().int().positive(),
13549
+ /**
13550
+ * A second dimension inside the family: the model / step id for an inference
13551
+ * timeout, so "which camera AND which model" is one read. Absent when the
13552
+ * family has a single variant.
13553
+ */
13554
+ variant: string().optional(),
13555
+ /**
13556
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13557
+ * differencing two reads must drop the interval when it changes, because the
13558
+ * counter restarted from zero in a respawned runner. Same discipline as
13559
+ * `LoadContribution.startedAtMs`.
13560
+ */
13561
+ sinceMs: number(),
13562
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13563
+ atMs: number(),
13564
+ /**
13565
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13566
+ * window. A failure count published without it is the mistake this schema
13567
+ * exists to make impossible.
13568
+ */
13569
+ attempts: number().int().nonnegative(),
13570
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13571
+ succeeded: number().int().nonnegative(),
13572
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13573
+ reasons: array(FailureReasonCountSchema).readonly()
13574
+ });
13575
+ method(_void(), array(FailureContributionSchema).readonly());
13446
13576
  var LoadContributionSchema = object({
13447
13577
  role: _enum([
13448
13578
  "decode",
@@ -17971,6 +18101,20 @@ var TrackSchema = object({
17971
18101
  * `=== true` and render nothing otherwise — never infer "no rider".
17972
18102
  */
17973
18103
  hasRider: boolean().optional(),
18104
+ /**
18105
+ * WHY this track ended without a NATIVE best-shot tile
18106
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18107
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18108
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18109
+ * the late-keyFrame upgrade when a native tile lands after all. The
18110
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18111
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18112
+ *
18113
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18114
+ * that predates the field, and every track whose tile landed native all
18115
+ * omit it. Render nothing when absent.
18116
+ */
18117
+ previewMissReason: string().optional(),
17974
18118
  ...TrackFlagFields,
17975
18119
  ...TrackRetrainFields
17976
18120
  });
@@ -27216,6 +27360,13 @@ var LoggingSettingsPatchSchema = object({
27216
27360
  * anyone but its owner.
27217
27361
  */
27218
27362
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27363
+ /**
27364
+ * One per-camera failure counter, plus WHO reported it.
27365
+ *
27366
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27367
+ * the hub as it enumerates providers, never by the contributor.
27368
+ */
27369
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27219
27370
  var GetLoggingSettingsInputSchema = object({
27220
27371
  scopeNodeId: string().optional(),
27221
27372
  /**
@@ -27274,7 +27425,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27274
27425
  }), method(_void(), SiteLocationStatusSchema, {
27275
27426
  kind: "mutation",
27276
27427
  auth: "admin"
27277
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27428
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27278
27429
  kind: "mutation",
27279
27430
  auth: "admin"
27280
27431
  });
@@ -29856,6 +30007,12 @@ Object.freeze({
29856
30007
  addonId: null,
29857
30008
  access: "create"
29858
30009
  },
30010
+ "failureContribution.list": {
30011
+ capName: "failure-contribution",
30012
+ capScope: "system",
30013
+ addonId: null,
30014
+ access: "view"
30015
+ },
29859
30016
  "fanControl.setDirection": {
29860
30017
  capName: "fan-control",
29861
30018
  capScope: "device",
@@ -33162,6 +33319,12 @@ Object.freeze({
33162
33319
  addonId: null,
33163
33320
  access: "create"
33164
33321
  },
33322
+ "system.getFailureContributions": {
33323
+ capName: "system",
33324
+ capScope: "system",
33325
+ addonId: null,
33326
+ access: "view"
33327
+ },
33165
33328
  "system.getLoadContributions": {
33166
33329
  capName: "system",
33167
33330
  capScope: "system",
@@ -36049,6 +36212,32 @@ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
36049
36212
  var MB = 1024 * 1024;
36050
36213
  1024 * MB, 3072 * MB;
36051
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
36052
36241
  //#region src/google-home/trait-catalog.ts
36053
36242
  /**
36054
36243
  * The trait catalog — one ROW per camstack capability, and that row is the
@@ -36077,7 +36266,7 @@ var MB = 1024 * 1024;
36077
36266
  * Cameras. Google's `CameraStream` trait answers `GetCameraStream` with a
36078
36267
  * playable URL or a WebRTC signaling endpoint, and neither exists here yet, so
36079
36268
  * a camera would be declared and then fail every command. It is omitted rather
36080
- * than declared — see docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36269
+ * than declared — see docs/decisions/adr-0273-one-capability-is-one-trait-row-that-declares-reads-and-writes.md.
36081
36270
  */
36082
36271
  /**
36083
36272
  * The Google state a SUCCEEDED call establishes.
@@ -36826,9 +37015,262 @@ function buildGoogleOauthIntegration() {
36826
37015
  };
36827
37016
  }
36828
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
36829
37270
  //#region src/types.ts
36830
37271
  var DEFAULT_SETTINGS = {
36831
37272
  publicHubUrl: "",
37273
+ detectedPublicHubUrl: "",
36832
37274
  exposed: [],
36833
37275
  linkedAccounts: []
36834
37276
  };
@@ -36867,7 +37309,7 @@ var DEFAULT_SETTINGS = {
36867
37309
  *
36868
37310
  * Switches, dimmable lights, locks and covers — the non-camera fleet neither
36869
37311
  * `export-hap` nor `export-alexa` covers. Cameras are out of scope on purpose
36870
- * (docs/decisions/adr-0268-one-capability-is-one-trait-row-that-declares-reads-and-writes.md); the trait catalog is the single place that
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
36871
37313
  * decides, so widening scope is one row.
36872
37314
  */
36873
37315
  var ADDON_ID = "export-google";
@@ -36882,6 +37324,9 @@ var ExportGoogleAddon = class extends BaseAddon {
36882
37324
  logger: this.ctx.logger
36883
37325
  });
36884
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
+ });
36885
37330
  const deviceExportProvider = {
36886
37331
  getStatus: async () => ({
36887
37332
  linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
@@ -36921,26 +37366,93 @@ var ExportGoogleAddon = class extends BaseAddon {
36921
37366
  }
36922
37367
  }));
36923
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());
36924
37375
  this.ctx.logger.info("export-google: initialized", { meta: {
36925
37376
  exposedCount: this.config.exposed.length,
36926
37377
  linkedAccounts: this.config.linkedAccounts.length,
36927
37378
  publicHubUrlSet: this.config.publicHubUrl.length > 0,
36928
37379
  willReportState: false
36929
37380
  } });
36930
- return { providers: [
36931
- {
36932
- capability: deviceExportCapability,
36933
- provider: deviceExportProvider
36934
- },
36935
- {
36936
- capability: addonRoutesCapability,
36937
- provider: routeProvider
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
+ }));
36938
37420
  },
36939
- {
36940
- capability: oauthIntegrationCapability,
36941
- provider: oauthIntegrationProvider
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
+ };
36942
37430
  }
36943
- ] };
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
+ };
36944
37456
  }
36945
37457
  async onShutdown() {
36946
37458
  this.gateway = null;
@@ -37010,65 +37522,54 @@ var ExportGoogleAddon = class extends BaseAddon {
37010
37522
  * tool for a real credential anyway (docs/decisions/adr-0269-a-google-export-that-holds-no-google-credential.md).
37011
37523
  */
37012
37524
  buildSetupBlock() {
37013
- const origin = this.config.publicHubUrl.replace(/\/+$/, "");
37014
- const linked = this.config.linkedAccounts.length;
37015
- if (origin.length === 0) return {
37016
- note: "Set the public hub URL on the settings form first — every URL Google needs is derived from it.",
37017
- fields: [{
37018
- label: "Linked Google accounts",
37019
- value: String(linked)
37020
- }]
37021
- };
37022
- return {
37023
- note: [
37024
- "In the Google Home Developer Console create a cloud-to-cloud integration, then paste these three URLs.",
37025
- "The integration stays in test mode: publishing requires Google certification, which a private hub cannot obtain.",
37026
- "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."
37027
- ].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,
37028
37537
  fields: [
37029
37538
  {
37030
- label: "Fulfillment URL",
37031
- value: `${origin}/addon/${ADDON_ID}/fulfillment`
37032
- },
37033
- {
37034
- label: "Authorization URL",
37035
- value: `${origin}/api/oauth2/authorize?integration=${ADDON_ID}`
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")
37036
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
+ }),
37037
37557
  {
37038
- label: "Token URL",
37039
- value: `${origin}/api/oauth2/token`
37558
+ type: "info",
37559
+ key: "__public-hub-url-source",
37560
+ label: "Where this came from",
37561
+ ...buildPublicHubUrlNotice(this.publicHubUrlState())
37040
37562
  },
37041
37563
  {
37042
- label: "Linked Google accounts",
37043
- value: String(linked)
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"
37044
37571
  }
37045
37572
  ]
37046
- };
37047
- }
37048
- globalSettingsSchema() {
37049
- return this.schema({ sections: [{
37050
- id: ADDON_ID,
37051
- title: "Google Home export",
37052
- 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.",
37053
- columns: 1,
37054
- fields: [{
37055
- type: "info",
37056
- key: "__google-setup-banner",
37057
- label: "Setup overview",
37058
- variant: "info",
37059
- content: [
37060
- "This hub must be reachable from the public internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …) — Google calls the fulfillment URL from its own cloud.",
37061
- "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.",
37062
- "Cameras are not exported, and state is not pushed: Google polls. Ask \"Hey Google, sync my devices\" after exposing something new."
37063
- ].join("\n")
37064
- }, this.field({
37065
- type: "text",
37066
- key: "publicHubUrl",
37067
- label: "Public hub URL",
37068
- 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.",
37069
- placeholder: "https://hub.example.com",
37070
- default: DEFAULT_SETTINGS.publicHubUrl
37071
- })]
37072
37573
  }] });
37073
37574
  }
37074
37575
  /**