@camstack/addon-provider-homeassistant 1.2.653 → 1.2.655

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as humidifierCapability, $t as boolean, A as brightnessCapability, At as switchCapability, Bt as weatherCapability, C as alarmPanelCapability, Ct as runningFromLawnMower, D as bestLocationMatch, Dt as scriptRunnerCapability, E as batteryCapability, Et as runningStateCapability, F as calendarSourceCapability, Ft as updateCapability, G as deviceAdminLinkCapability, H as contactCapability, Ht as RecordSecretUnavailableError, I as carbonMonoxideCapability, It as vacuumControlCapability, J as enumSensorCapability, Jt as entryBody, K as deviceAdoptionCapability, Kt as defineEntryKind, L as climateControlCapability, Lt as valveCapability, M as buildAddonRouteProvider, Mt as temperatureSensorCapability, N as buildDeviceAdminUrl, Nt as tryConvertDelta, O as binaryCapability, P as buttonCapability, Pt as tryConvertUnit, Q as gasCapability, Qt as array, R as colorCapability, Rt as vibrationCapability, S as airQualitySensorCapability, T as automationControlCapability, Tt as runningFromVacuumState, U as controlCapability, Ut as baseUnitOf, W as coverCapability, Wt as createEvent, X as fanControlCapability, Y as eventEmitterCapability, Z as floodCapability, Zt as _enum, _t as pressureSensorCapability, a as BaseDevice, an as string, at as mediaPlayerCapability, b as accessoriesCapability, bt as quantityFromUnit, cn as url, ct as notificationOutputCapability, dn as DeviceType, en as discriminatedUnion, et as humiditySensorCapability, fn as errMsg, ft as parseAdminBase, gt as presenceCapability, ht as prepareNotification, in as record, it as lockControlCapability, j as brokerCapability, jt as tamperCapability, k as boardKeySourceCapability, kt as smokeCapability, ln as DeviceFeature, lt as notifierCapability, nn as number, nt as ingestReading, o as BaseDeviceProvider, on as union, ot as motionCapability, p as EnumSensorDateTimeFormatSchema, pn as EventCategory, pt as powerMeterCapability, qt as defineRecordKinds, rn as object, rt as lawnMowerControlCapability, sn as unknown, st as normalizeUnit, t as HA_BROKER_KIND, tn as literal, tt as imageCapability, un as DeviceRole, ut as numericSensorCapability, v as RUNNING_STATE_COLD, w as ambientLightSensorCapability, wt as runningFromMediaPlayer, x as addonRoutesCapability, xt as resolveAttachmentUrls, y as TargetSchema, yt as publicEndpointReaderFrom, z as connectivityCapability, zt as waterHeaterCapability } from "./ha-broker-source-CKdH9XHn.mjs";
1
+ import { $ as enumSensorCapability, $t as baseUnitOf, A as automationControlCapability, B as calendarSourceCapability, Bt as tamperCapability, C as RecordIntegrationInstances, Cn as EventCategory, Ct as prepareNotification, D as airQualitySensorCapability, Dt as publicEndpointReaderFrom, E as addonRoutesCapability, F as brightnessCapability, Ft as scriptRunnerCapability, Gt as vacuumControlCapability, H as climateControlCapability, Ht as tryConvertDelta, I as brokerCapability, J as controlCapability, Jt as waterHeaterCapability, Kt as valveCapability, L as buildAddonRouteProvider, Lt as smokeCapability, M as bestLocationMatch, Mt as runningFromMediaPlayer, N as binaryCapability, Nt as runningFromVacuumState, O as alarmPanelCapability, Ot as quantityFromUnit, P as boardKeySourceCapability, Pt as runningStateCapability, Qt as RecordSecretUnavailableError, R as buildDeviceAdminUrl, S as RUNNING_STATE_COLD, Sn as errMsg, St as preconditionOf, T as accessoriesCapability, Tt as pressureSensorCapability, U as colorCapability, Ut as tryConvertUnit, V as carbonMonoxideCapability, Vt as temperatureSensorCapability, W as connectivityCapability, Wt as updateCapability, X as deviceAdminLinkCapability, Y as coverCapability, Yt as weatherCapability, Z as deviceAdoptionCapability, _n as unknown, _t as notifierCapability, at as gasCapability, bn as DeviceRole, bt as parseAdminBase, c as BaseDevice, cn as array, ct as imageCapability, dn as literal, dt as lawnMowerControlCapability, en as createEvent, et as eventEmitterCapability, fn as number, ft as lockControlCapability, g as EnumSensorDateTimeFormatSchema, gn as union, gt as notificationOutputCapability, hn as string, ht as normalizeUnit, i as HA_BROKER_KIND, in as entryBody, it as floodCapability, j as batteryCapability, jt as runningFromLawnMower, k as ambientLightSensorCapability, kt as resolveAttachmentUrls, l as BaseDeviceProvider, ln as boolean, lt as ingestReading, mn as record, mt as motionCapability, nn as defineEntryKind, nt as flattenNestedRecords, ot as humidifierCapability, pn as object, pt as mediaPlayerCapability, q as contactCapability, qt as vibrationCapability, rn as defineRecordKinds, rt as flattenPortsOver, sn as _enum, st as humiditySensorCapability, t as haInstanceSettingsPortsOver, tn as defineAddonMigrations, tt as fanControlCapability, un as discriminatedUnion, ut as integrationInstancesCapability, vn as url, vt as numericSensorCapability, w as TargetSchema, wt as presenceCapability, xn as DeviceType, xt as powerMeterCapability, yn as DeviceFeature, z as buttonCapability, zt as switchCapability } from "./ha-instance-settings-DLDNOmgL.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-admin-link.ts
@@ -2496,11 +2496,33 @@ var HaMediaPlayerDevice = class extends HaBaseChildDevice {
2496
2496
  registerRunningCap() {
2497
2497
  this.ctx.registerNativeCap(runningStateCapability, { getStatus: async () => this.runtimeState.getCapState("running-state") ?? RUNNING_STATE_COLD });
2498
2498
  }
2499
+ /** One service call per step, in order: HA has no "N steps" form. */
2500
+ async repeatHaService(service, steps) {
2501
+ for (let step = 0; step < steps; step += 1) await this.callHaService("media_player", service);
2502
+ }
2499
2503
  registerMediaPlayerCap() {
2500
2504
  this.ctx.registerNativeCap(mediaPlayerCapability, {
2501
2505
  getStatus: async () => {
2502
2506
  return this.runtimeState.getCapState("media-player") ?? COLD_START$10;
2503
2507
  },
2508
+ turnOn: async () => {
2509
+ await this.callHaService("media_player", "turn_on");
2510
+ },
2511
+ turnOff: async () => {
2512
+ await this.callHaService("media_player", "turn_off");
2513
+ },
2514
+ volumeUp: async ({ steps }) => {
2515
+ await this.repeatHaService("volume_up", steps);
2516
+ },
2517
+ volumeDown: async ({ steps }) => {
2518
+ await this.repeatHaService("volume_down", steps);
2519
+ },
2520
+ channelUp: async ({ steps }) => {
2521
+ await this.repeatHaService("media_next_track", steps);
2522
+ },
2523
+ channelDown: async ({ steps }) => {
2524
+ await this.repeatHaService("media_previous_track", steps);
2525
+ },
2504
2526
  play: async () => {
2505
2527
  await this.callHaService("media_player", "media_play");
2506
2528
  },
@@ -5742,22 +5764,16 @@ var TEST_HANDSHAKE_TIMEOUT_MS = 8e3;
5742
5764
  var HaBrokerEntrySchema = object({
5743
5765
  id: string(),
5744
5766
  name: string(),
5745
- /** Either Supervisor (token from env) or long-lived (persisted). */
5746
- auth: discriminatedUnion("kind", [object({
5747
- kind: literal("supervisor"),
5748
- supervisorToken: string().meta({ secret: true })
5749
- }), object({
5750
- kind: literal("long-lived"),
5751
- baseUrl: string(),
5752
- accessToken: string().meta({ secret: true })
5753
- })]),
5754
- /** Optional FK to the integration record that spawned this broker.
5755
- * Populated when the wizard's "+ New Integration" flow created the
5756
- * broker; null when the operator added it via the Settings panel
5757
- * directly. Used by the integration listener to keep the broker
5758
- * list in sync with the integrations table (cascade-delete on
5759
- * integration removal). */
5760
- integrationId: string().optional()
5767
+ /** Supervisor (token from env) or long-lived (persisted); picks which fields below are used. */
5768
+ authKind: _enum(["long-lived", "supervisor"]),
5769
+ /** Origin URL of the HA instance; long-lived only. */
5770
+ baseUrl: string().optional(),
5771
+ accessToken: string().optional().meta({ secret: true }),
5772
+ supervisorToken: string().optional().meta({ secret: true }),
5773
+ /** Optional FK to the integration record that spawned this broker. */
5774
+ integrationId: string().optional(),
5775
+ /** The instance's own switch (D996); absent means on. */
5776
+ enabled: boolean().optional()
5761
5777
  });
5762
5778
  /**
5763
5779
  * Validates a `broker.add` `settings` payload for the HA kind. Accepts:
@@ -5782,6 +5798,34 @@ function settingsToAuth(settings) {
5782
5798
  accessToken: parsed.accessToken
5783
5799
  };
5784
5800
  }
5801
+ /** The flat entry fields of an auth config: the one place the two shapes meet. */
5802
+ function authToEntryFields(auth) {
5803
+ return auth.kind === "supervisor" ? {
5804
+ authKind: "supervisor",
5805
+ supervisorToken: auth.supervisorToken
5806
+ } : {
5807
+ authKind: "long-lived",
5808
+ baseUrl: auth.baseUrl,
5809
+ accessToken: auth.accessToken
5810
+ };
5811
+ }
5812
+ /** The auth config an entry stands for; null while the entry is not filled in. */
5813
+ function haAuthOf(entry) {
5814
+ const parsed = HaAuthConfigSchema.safeParse(entry.authKind === "supervisor" ? {
5815
+ kind: "supervisor",
5816
+ supervisorToken: entry.supervisorToken ?? ""
5817
+ } : {
5818
+ kind: "long-lived",
5819
+ baseUrl: entry.baseUrl ?? "",
5820
+ accessToken: entry.accessToken ?? ""
5821
+ });
5822
+ return parsed.success ? parsed.data : null;
5823
+ }
5824
+ /** The flat settings payload (`settingsToAuth` shape) of an entry; null while it is not filled in. */
5825
+ function entryToSettings(entry) {
5826
+ const auth = haAuthOf(entry);
5827
+ return auth === null ? null : authToSettings(auth);
5828
+ }
5785
5829
  function authToSettings(auth) {
5786
5830
  if (auth.kind === "supervisor") return { supervisorToken: auth.supervisorToken };
5787
5831
  return {
@@ -5844,7 +5888,7 @@ var HaBrokerRegistry = class {
5844
5888
  * started concurrently; transient errors don't block bootstrap. */
5845
5889
  async restore(entries) {
5846
5890
  this.seedCounterFromEntries(entries);
5847
- await Promise.all(entries.map(async (entry) => {
5891
+ await Promise.all(entries.filter((entry) => entry.enabled !== false && haAuthOf(entry) !== null).map(async (entry) => {
5848
5892
  try {
5849
5893
  await this.startManagerForEntry(entry);
5850
5894
  } catch (err) {
@@ -5905,7 +5949,7 @@ var HaBrokerRegistry = class {
5905
5949
  const entry = {
5906
5950
  id: options.id !== void 0 ? this.adoptId(options.id) : this.allocateId(),
5907
5951
  name,
5908
- auth,
5952
+ ...authToEntryFields(auth),
5909
5953
  ...options.integrationId !== void 0 ? { integrationId: options.integrationId } : {}
5910
5954
  };
5911
5955
  await this.startManagerForEntry(entry);
@@ -5926,7 +5970,7 @@ var HaBrokerRegistry = class {
5926
5970
  return {
5927
5971
  id,
5928
5972
  name: existing.getInfo().name,
5929
- auth
5973
+ ...authToEntryFields(auth)
5930
5974
  };
5931
5975
  }
5932
5976
  /**
@@ -6109,10 +6153,12 @@ var HaBrokerRegistry = class {
6109
6153
  this.logger.debug("ha-broker already active — reusing existing connection", { meta: { brokerId: entry.id } });
6110
6154
  return;
6111
6155
  }
6156
+ const auth = haAuthOf(entry);
6157
+ if (auth === null) throw new Error(`ha-broker '${entry.id}' has no complete credentials`);
6112
6158
  const manager = new HaIntegrationManager({
6113
6159
  id: entry.id,
6114
6160
  name: entry.name,
6115
- auth: entry.auth,
6161
+ auth,
6116
6162
  logger: this.logger,
6117
6163
  eventBus: this.eventBus,
6118
6164
  onConnected: (brokerId) => this.onBrokerConnected?.(brokerId),
@@ -6439,6 +6485,8 @@ function mapMediaPlayer(attributes) {
6439
6485
  if ((sf & 8) !== 0) features.push(DeviceFeature.MediaPlayerMute);
6440
6486
  if ((sf & 16) !== 0) features.push(DeviceFeature.MediaPlayerPrevious);
6441
6487
  if ((sf & 32) !== 0) features.push(DeviceFeature.MediaPlayerNext);
6488
+ if ((sf & 384) === 384) features.push(DeviceFeature.MediaPlayerPower);
6489
+ if ((sf & 1024) !== 0) features.push(DeviceFeature.MediaPlayerVolumeStep);
6442
6490
  if ((sf & 512) !== 0) features.push(DeviceFeature.MediaPlayerPlayMedia);
6443
6491
  if ((sf & 2048) !== 0) features.push(DeviceFeature.MediaPlayerSelectSource);
6444
6492
  if ((sf & 4096) !== 0) features.push(DeviceFeature.MediaPlayerStop);
@@ -7170,10 +7218,15 @@ function createHaIconRouteProvider() {
7170
7218
  var haBrokersEntryKind = defineEntryKind("brokers", {
7171
7219
  title: "Home Assistant brokers",
7172
7220
  schema: entryBody({
7173
- auth: HaBrokerEntrySchema.shape.auth,
7174
- integrationId: string().optional()
7221
+ authKind: HaBrokerEntrySchema.shape.authKind,
7222
+ baseUrl: HaBrokerEntrySchema.shape.baseUrl,
7223
+ accessToken: HaBrokerEntrySchema.shape.accessToken,
7224
+ supervisorToken: HaBrokerEntrySchema.shape.supervisorToken,
7225
+ integrationId: HaBrokerEntrySchema.shape.integrationId,
7226
+ enabled: HaBrokerEntrySchema.shape.enabled
7175
7227
  }),
7176
- writeTrigger: "an operator adds, edits or removes a Home Assistant broker connection"
7228
+ writeTrigger: "an operator adds, edits or removes a Home Assistant broker connection",
7229
+ retiredSecretPaths: ["auth.accessToken", "auth.supervisorToken"]
7177
7230
  });
7178
7231
  var haRecordKinds = defineRecordKinds({
7179
7232
  /**
@@ -7695,8 +7748,12 @@ function rowToEntry(id, data) {
7695
7748
  const parsed = HaBrokerEntrySchema.safeParse({
7696
7749
  id,
7697
7750
  name: data.name,
7698
- auth: data.auth,
7699
- integrationId: data.integrationId
7751
+ authKind: data.authKind,
7752
+ baseUrl: data.baseUrl,
7753
+ accessToken: data.accessToken,
7754
+ supervisorToken: data.supervisorToken,
7755
+ integrationId: data.integrationId,
7756
+ enabled: data.enabled
7700
7757
  });
7701
7758
  return parsed.success ? parsed.data : null;
7702
7759
  }
@@ -7735,10 +7792,9 @@ var HaBrokerStore = class HaBrokerStore {
7735
7792
  }
7736
7793
  }
7737
7794
  async upsert(entry) {
7738
- const { id, name, auth, integrationId } = entry;
7795
+ const { id, integrationId, ...fields } = entry;
7739
7796
  const body = {
7740
- name,
7741
- auth,
7797
+ ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== void 0)),
7742
7798
  ...integrationId === void 0 ? {} : { integrationId }
7743
7799
  };
7744
7800
  const parsed = haBrokersEntryKind.schema.safeParse(body);
@@ -7759,6 +7815,97 @@ var HaBrokerStore = class HaBrokerStore {
7759
7815
  }
7760
7816
  };
7761
7817
  //#endregion
7818
+ //#region src/ha-instances.ts
7819
+ /**
7820
+ * The Home Assistant connections as `integration-instances` (planning#408 P3): one instance per
7821
+ * `brokers` record, over the SAME records and ids (`ha_NNN`) the `broker` cap serves. The body is
7822
+ * flat, so the form the instances page edits is the stored shape.
7823
+ */
7824
+ var HA_INSTANCE_KIND = "home-assistant";
7825
+ function isHaBodyConfigured(body) {
7826
+ return body.authKind === "supervisor" ? (body.supervisorToken ?? "").length > 0 : (body.baseUrl ?? "").length > 0 && (body.accessToken ?? "").length > 0;
7827
+ }
7828
+ function statusOf(info) {
7829
+ if (info === null) return {
7830
+ state: "unknown",
7831
+ label: "Not connected"
7832
+ };
7833
+ if (info.status === "connected") return {
7834
+ state: "ok",
7835
+ label: "Connected"
7836
+ };
7837
+ if (info.status === "connecting") return {
7838
+ state: "unknown",
7839
+ label: "Connecting"
7840
+ };
7841
+ return {
7842
+ state: "error",
7843
+ label: info.status,
7844
+ ...info.error !== null ? { detail: info.error } : {}
7845
+ };
7846
+ }
7847
+ function buildHaInstances(records, deps) {
7848
+ return new RecordIntegrationInstances(records, haBrokersEntryKind, {
7849
+ description: {
7850
+ instanceMode: "multiple",
7851
+ kind: "connection",
7852
+ canTest: true,
7853
+ kinds: [{
7854
+ id: HA_INSTANCE_KIND,
7855
+ label: "Home Assistant"
7856
+ }],
7857
+ allowRestoreId: true
7858
+ },
7859
+ formSchema: () => haBrokerSettingsSchema,
7860
+ initialBody: (name) => ({
7861
+ name,
7862
+ authKind: "long-lived"
7863
+ }),
7864
+ mintId: () => deps.mintId(),
7865
+ kindOf: () => HA_INSTANCE_KIND,
7866
+ health: (_body, id) => {
7867
+ const info = deps.infoOf(id);
7868
+ return {
7869
+ status: statusOf(info),
7870
+ ...info?.lastCheckedAt != null ? { lastCheckedAt: info.lastCheckedAt } : {}
7871
+ };
7872
+ },
7873
+ configured: isHaBodyConfigured,
7874
+ onChanged: deps.onChanged,
7875
+ test: async ({ id, values }) => {
7876
+ if (["accessToken", "supervisorToken"].some((key) => {
7877
+ const v = values[key];
7878
+ return typeof v === "string" && v.length > 0 && v !== "__camstack_redacted__";
7879
+ })) {
7880
+ const r = await deps.probe(values);
7881
+ return r.ok ? {
7882
+ success: true,
7883
+ status: "validated",
7884
+ ...r.latencyMs !== void 0 ? { latencyMs: r.latencyMs } : {}
7885
+ } : {
7886
+ success: false,
7887
+ status: "rejected",
7888
+ error: r.error ?? "Home Assistant rejected the connection"
7889
+ };
7890
+ }
7891
+ const info = id === null ? null : deps.infoOf(id);
7892
+ if (info === null) return {
7893
+ success: false,
7894
+ status: "inconclusive",
7895
+ error: "Not connected: save the connection first"
7896
+ };
7897
+ return info.status === "connected" ? {
7898
+ success: true,
7899
+ status: "validated"
7900
+ } : {
7901
+ success: false,
7902
+ status: "rejected",
7903
+ error: info.error ?? `status: ${info.status}`
7904
+ };
7905
+ }
7906
+ });
7907
+ }
7908
+ //#endregion
7762
7909
  //#region src/ha-board-key-source.ts
7763
7910
  /**
7764
7911
  * `board-key-source` for Home Assistant: reads an ESPHome board's API
@@ -7932,6 +8079,178 @@ function createHaBoardKeyProvider(source) {
7932
8079
  };
7933
8080
  }
7934
8081
  //#endregion
8082
+ //#region src/migrations/flatten-broker-records.ts
8083
+ /**
8084
+ * The Home Assistant broker records keep their connection flat (planning#408 P3): the nested
8085
+ * `auth` object (`{ kind, baseUrl, accessToken }` or `{ kind, supervisorToken }`) becomes the top
8086
+ * level `authKind`, `baseUrl`, `accessToken` and `supervisorToken`, under the SAME record id, so
8087
+ * the devices that carry `ha_NNN` keep their broker. The work is `flattenNestedRecords`: every
8088
+ * secret is read before anything is written, read back from its new keychain ref before the
8089
+ * nested object goes, and the old refs are deleted last.
8090
+ */
8091
+ var FLATTEN_BROKER_RECORDS_ID = "provider-homeassistant/2026-11-04-flatten-broker-records";
8092
+ function text(value) {
8093
+ return typeof value === "string" ? value : void 0;
8094
+ }
8095
+ async function flattenBrokerRecords(ports) {
8096
+ return flattenNestedRecords(ports, {
8097
+ legacyKey: "auth",
8098
+ flatSecretKeys: ["accessToken", "supervisorToken"],
8099
+ toFlat: (legacy) => {
8100
+ const kind = legacy["kind"] === "supervisor" ? "supervisor" : "long-lived";
8101
+ const baseUrl = text(legacy["baseUrl"]);
8102
+ const accessToken = text(legacy["accessToken"]);
8103
+ const supervisorToken = text(legacy["supervisorToken"]);
8104
+ return {
8105
+ authKind: kind,
8106
+ ...baseUrl !== void 0 ? { baseUrl } : {},
8107
+ ...accessToken !== void 0 ? { accessToken } : {},
8108
+ ...supervisorToken !== void 0 ? { supervisorToken } : {}
8109
+ };
8110
+ }
8111
+ });
8112
+ }
8113
+ //#endregion
8114
+ //#region src/migrations/calendar-ids-to-integration.ts
8115
+ /**
8116
+ * The HA calendars an instance imports are a fact of THAT instance (D985): each Home Assistant
8117
+ * integration carries its own `calendarIds` in its settings, and the addon-level `calendarIds`
8118
+ * setting (one set of `calendar.*` entity ids for every broker) goes away.
8119
+ *
8120
+ * Two migrations, in this order:
8121
+ *
8122
+ * 1. `calendar-ids-to-integration` (procedure) copies the shared set to EVERY integration that
8123
+ * has no `calendarIds` yet. An integration that already has one is never overwritten; one
8124
+ * deleted meanwhile is skipped, never recreated; a stored value that is not a list of strings
8125
+ * is an ambiguity and throws (nothing is guessed).
8126
+ * 2. `drop-shared-calendar-ids` (plan) deletes the shared setting after a read-back: every
8127
+ * integration must show its own set. When one holds a set that differs from the shared one,
8128
+ * the shared value is the loser and stays PARKED in place, never deleted.
8129
+ *
8130
+ * Both are idempotent.
8131
+ */
8132
+ var HA_PROVIDER_ID = "provider-homeassistant";
8133
+ /** The canonical settings table: one row per key, id `<addonId>.<key>`. */
8134
+ var ADDON_SETTINGS_TABLE = "addon-settings";
8135
+ var SHARED_CALENDAR_IDS_KEY = "calendarIds";
8136
+ async function readSharedSet(ports) {
8137
+ const answer = await ports.readStored(SHARED_CALENDAR_IDS_KEY);
8138
+ if (answer.kind === "unreadable") throw new Error(`provider-homeassistant: the shared calendarIds cannot be read (${answer.reason})`);
8139
+ if (answer.kind === "absent") return null;
8140
+ const value = answer.value;
8141
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("provider-homeassistant: the shared calendarIds is not a list of entity ids — nothing copied");
8142
+ return value;
8143
+ }
8144
+ async function copyCalendarIdsToIntegrations(ports) {
8145
+ const shared = await readSharedSet(ports);
8146
+ if (shared === null) return {
8147
+ outcome: "already-done",
8148
+ summary: { reason: "no-shared-set" }
8149
+ };
8150
+ let copied = 0;
8151
+ let kept = 0;
8152
+ let gone = 0;
8153
+ for (const instance of await ports.listInstances()) {
8154
+ if ("calendarIds" in instance.settings) {
8155
+ kept += 1;
8156
+ continue;
8157
+ }
8158
+ if (await ports.patchInstanceSettings(instance.integrationId, { calendarIds: [...shared] })) copied += 1;
8159
+ else gone += 1;
8160
+ }
8161
+ return {
8162
+ outcome: copied === 0 ? "already-done" : "done",
8163
+ summary: {
8164
+ copied,
8165
+ kept,
8166
+ gone
8167
+ }
8168
+ };
8169
+ }
8170
+ var asIds = (value) => Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null;
8171
+ var sameSet = (a, b) => {
8172
+ const left = new Set(a);
8173
+ const right = new Set(b);
8174
+ return left.size === right.size && [...left].every((id) => right.has(id));
8175
+ };
8176
+ async function planDropSharedCalendarIds(ports) {
8177
+ const shared = await readSharedSet(ports);
8178
+ if (shared === null) return { edits: [] };
8179
+ const instances = await ports.listInstances();
8180
+ if (instances.length === 0) return { edits: [] };
8181
+ const own = instances.map((instance) => ({
8182
+ integrationId: instance.integrationId,
8183
+ ids: asIds(instance.settings[SHARED_CALENDAR_IDS_KEY])
8184
+ }));
8185
+ const missing = own.filter((entry) => entry.ids === null);
8186
+ if (missing.length > 0) throw new Error(`provider-homeassistant: the shared calendarIds is not dropped, ${missing.map((m) => m.integrationId).join(", ")} has no set of its own`);
8187
+ if (!own.every((entry) => sameSet(entry.ids ?? [], shared))) return { edits: [] };
8188
+ const rowId = `${HA_PROVIDER_ID}.${SHARED_CALENDAR_IDS_KEY}`;
8189
+ return { edits: [{
8190
+ op: "deleteKeys",
8191
+ scope: ADDON_SETTINGS_TABLE,
8192
+ keys: [rowId],
8193
+ precondition: preconditionOf({ [rowId]: {
8194
+ addonId: HA_PROVIDER_ID,
8195
+ key: SHARED_CALENDAR_IDS_KEY,
8196
+ value: shared
8197
+ } })
8198
+ }] };
8199
+ }
8200
+ //#endregion
8201
+ //#region src/migrations/index.ts
8202
+ /** The `provider-homeassistant` catalogue (D987): offered to the engine when the addon initialises. */
8203
+ var ADDON_MIGRATIONS = [
8204
+ {
8205
+ meta: {
8206
+ id: "provider-homeassistant/2026-11-03-calendar-ids-to-integration",
8207
+ owner: "provider-homeassistant",
8208
+ level: "addon-state",
8209
+ seq: 1,
8210
+ dependsOn: [],
8211
+ store: "settings",
8212
+ scope: "cluster",
8213
+ onFailure: "tolerate",
8214
+ bodyKind: "procedure",
8215
+ description: "Copy the shared calendar set to the settings of every Home Assistant integration that has none",
8216
+ fixtureMark: "the broker link stored beside the calendars of an integration"
8217
+ },
8218
+ procedure: copyCalendarIdsToIntegrations
8219
+ },
8220
+ {
8221
+ meta: {
8222
+ id: "provider-homeassistant/2026-11-03-drop-shared-calendar-ids",
8223
+ owner: "provider-homeassistant",
8224
+ level: "addon-state",
8225
+ seq: 2,
8226
+ dependsOn: ["provider-homeassistant/2026-11-03-calendar-ids-to-integration"],
8227
+ store: "settings",
8228
+ scope: "cluster",
8229
+ onFailure: "tolerate",
8230
+ bodyKind: "plan",
8231
+ description: "Delete the shared calendar set once every integration shows its own",
8232
+ fixtureMark: "the shared set parked when an integration holds a different one"
8233
+ },
8234
+ plan: planDropSharedCalendarIds
8235
+ },
8236
+ {
8237
+ meta: {
8238
+ id: FLATTEN_BROKER_RECORDS_ID,
8239
+ owner: "provider-homeassistant",
8240
+ level: "addon-state",
8241
+ seq: 3,
8242
+ dependsOn: [],
8243
+ store: "settings",
8244
+ scope: "cluster",
8245
+ onFailure: "block-owner",
8246
+ bodyKind: "procedure",
8247
+ description: "Flatten the nested auth of every Home Assistant broker record under its own id, moving each token to a top-level keychain secret",
8248
+ fixtureMark: "a non-secret field stored beside the auth of a broker record"
8249
+ },
8250
+ procedure: flattenBrokerRecords
8251
+ }
8252
+ ];
8253
+ //#endregion
7935
8254
  //#region src/ha-calendar-source.ts
7936
8255
  function isCalendarEntity(entityId) {
7937
8256
  return entityId.startsWith("calendar.");
@@ -8042,9 +8361,9 @@ function isSelected(entityId, selected) {
8042
8361
  function createHaCalendarSource(deps) {
8043
8362
  return {
8044
8363
  listCalendars: async () => {
8045
- const selected = deps.selectedCalendarIds();
8046
8364
  const out = [];
8047
- for (const { manager } of deps.brokers.listManagers()) {
8365
+ for (const { brokerId, manager } of deps.brokers.listManagers()) {
8366
+ const selected = deps.selectedCalendarIds(brokerId);
8048
8367
  const states = manager.snapshotAllStates();
8049
8368
  for (const [entityId, raw] of Object.entries(states)) {
8050
8369
  if (!isCalendarEntity(entityId)) continue;
@@ -8064,10 +8383,10 @@ function createHaCalendarSource(deps) {
8064
8383
  listEvents: async (input) => {
8065
8384
  const startIso = new Date(input.startMs).toISOString();
8066
8385
  const endIso = new Date(input.endMs).toISOString();
8067
- const selected = deps.selectedCalendarIds();
8068
8386
  const wanted = input.calendarIds !== void 0 && input.calendarIds.length > 0 ? new Set(input.calendarIds) : null;
8069
8387
  const out = [];
8070
8388
  for (const { brokerId, manager } of deps.brokers.listManagers()) {
8389
+ const selected = deps.selectedCalendarIds(brokerId);
8071
8390
  const states = manager.snapshotAllStates();
8072
8391
  const entityIds = Object.keys(states).filter((id) => {
8073
8392
  if (!isCalendarEntity(id)) return false;
@@ -8091,10 +8410,11 @@ function createHaCalendarSource(deps) {
8091
8410
  };
8092
8411
  }
8093
8412
  /** Discover every HA calendar entity (ignores the import filter) — for settings catalog. */
8094
- function listHaCalendarCatalog(brokers) {
8413
+ function listHaCalendarCatalog(brokers, onlyBrokerId) {
8095
8414
  const out = [];
8096
8415
  const seen = /* @__PURE__ */ new Set();
8097
- for (const { manager } of brokers.listManagers()) {
8416
+ for (const { brokerId, manager } of brokers.listManagers()) {
8417
+ if (onlyBrokerId !== void 0 && brokerId !== onlyBrokerId) continue;
8098
8418
  const states = manager.snapshotAllStates();
8099
8419
  for (const [entityId, raw] of Object.entries(states)) {
8100
8420
  if (!isCalendarEntity(entityId) || seen.has(entityId)) continue;
@@ -8543,10 +8863,7 @@ function planStandaloneReconcile(devices, freshById, brokerId) {
8543
8863
  var ADOPTION_FILTER_DEVICES = "devices";
8544
8864
  var ADOPTION_FILTER_ENTITIES = "entities";
8545
8865
  var HA_KIND = "home-assistant";
8546
- var DEFAULT_CONFIG = {
8547
- brokers: [],
8548
- calendarIds: []
8549
- };
8866
+ var DEFAULT_CONFIG = { calendarIds: [] };
8550
8867
  /**
8551
8868
  * Home Assistant provider addon.
8552
8869
  *
@@ -8593,6 +8910,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8593
8910
  }
8594
8911
  registry = null;
8595
8912
  brokerStore = null;
8913
+ instances = null;
8596
8914
  /** Runtime broker rows (records when B2 is live, else settings). */
8597
8915
  activeBrokers = [];
8598
8916
  /** Periodic re-sync sweep handle (cleared on shutdown). */
@@ -8607,11 +8925,26 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8607
8925
  constructor() {
8608
8926
  super({ ...DEFAULT_CONFIG });
8609
8927
  }
8928
+ flattenPorts(records) {
8929
+ if (this.ctx.secrets === void 0) throw new Error("provider-homeassistant: ctx.secrets is required");
8930
+ return {
8931
+ readStored: (key) => this.readStoredGlobalKey(key),
8932
+ ...haInstanceSettingsPortsOver(this.ctx.api.integrations),
8933
+ ...flattenPortsOver(records.kind(haBrokersEntryKind))
8934
+ };
8935
+ }
8610
8936
  async onInitialize() {
8611
8937
  const records = this.ctx.records;
8612
8938
  if (records === void 0) throw new Error("provider-homeassistant: ctx.records is required (hub-only addon)");
8613
8939
  await records.declare(haAllRecordKinds);
8614
8940
  this.brokerStore = HaBrokerStore.of(records);
8941
+ this.instances = buildHaInstances(records, {
8942
+ mintId: () => this.requireRegistry().allocateId(),
8943
+ infoOf: (id) => this.registry?.get(id) ?? null,
8944
+ onChanged: (id) => this.syncBrokerRuntime(id),
8945
+ probe: (settings) => this.requireRegistry().testSettings(settings)
8946
+ });
8947
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id === FLATTEN_BROKER_RECORDS_ID), this.flattenPorts(records)));
8615
8948
  this.activeBrokers = await this.loadPersistedBrokers();
8616
8949
  this.registry = new HaBrokerRegistry(this.ctx.logger, this.ctx.eventBus);
8617
8950
  this.registry.setOnBrokerConnected((brokerId) => {
@@ -8628,6 +8961,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8628
8961
  this.reconcileAllConnectedBrokers();
8629
8962
  }, HaProviderAddon.RESYNC_INTERVAL_MS);
8630
8963
  this.ctx.logger.info("ha provider initialised", { meta: { brokerCount: this.activeBrokers.length } });
8964
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id !== FLATTEN_BROKER_RECORDS_ID), this.flattenPorts(records)));
8631
8965
  await this.reconcileIntegrationsToBrokers();
8632
8966
  this.subscribeIntegrationLifecycle();
8633
8967
  return [
@@ -8635,6 +8969,10 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8635
8969
  capability: brokerCapability,
8636
8970
  provider: this.buildBrokerProvider()
8637
8971
  },
8972
+ {
8973
+ capability: integrationInstancesCapability,
8974
+ provider: this.requireInstances().toProvider()
8975
+ },
8638
8976
  {
8639
8977
  capability: deviceAdoptionCapability,
8640
8978
  provider: this.buildAdoptionProvider()
@@ -8656,7 +8994,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8656
8994
  provider: createHaCalendarSource({
8657
8995
  brokers: this.requireRegistry(),
8658
8996
  logger: this.ctx.logger.child("calendar-source"),
8659
- selectedCalendarIds: () => new Set(this.config.calendarIds ?? [])
8997
+ selectedCalendarIds: (brokerId) => this.calendarIdsOf(brokerId)
8660
8998
  })
8661
8999
  }
8662
9000
  ];
@@ -8754,6 +9092,20 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8754
9092
  *
8755
9093
  * Idempotent — runs on boot + on every integration.* event.
8756
9094
  */
9095
+ /**
9096
+ * Calendars to import, per broker (D985): the integration's own selection, filled by the
9097
+ * reconcile that already reads each integration's settings. A broker whose integration has no
9098
+ * selection yet (the move has not run) uses the legacy shared set, so a boot mid-move never
9099
+ * changes what is imported.
9100
+ */
9101
+ calendarIdsByBroker = /* @__PURE__ */ new Map();
9102
+ noteInstanceCalendars(brokerId, raw) {
9103
+ if (Array.isArray(raw) && raw.every((id) => typeof id === "string")) this.calendarIdsByBroker.set(brokerId, new Set(raw));
9104
+ else this.calendarIdsByBroker.delete(brokerId);
9105
+ }
9106
+ calendarIdsOf(brokerId) {
9107
+ return this.calendarIdsByBroker.get(brokerId) ?? new Set(this.config.calendarIds ?? []);
9108
+ }
8757
9109
  async reconcileIntegrationsToBrokers() {
8758
9110
  const api = this.ctx.api;
8759
9111
  if (!api.integrations?.list?.query || !api.integrations.getSettings?.query) return;
@@ -8765,6 +9117,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8765
9117
  const settings = await api.integrations.getSettings.query({ id: integration.id });
8766
9118
  const brokerId = typeof settings["brokerId"] === "string" ? settings["brokerId"] : null;
8767
9119
  if (!brokerId) continue;
9120
+ this.noteInstanceCalendars(brokerId, settings["calendarIds"]);
8768
9121
  if (!this.activeBrokers.find((b) => b.id === brokerId)) {
8769
9122
  this.ctx.logger.warn("integration→broker: linked broker not found", { meta: {
8770
9123
  integrationId: integration.id,
@@ -8804,23 +9157,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8804
9157
  eb.subscribe({ category: "integration.disabled" }, handler);
8805
9158
  eb.subscribe({ category: "integration.deleted" }, handler);
8806
9159
  }
8807
- globalSettingsSchema(cap) {
8808
- if (cap === "calendar-source") return this.schema({ sections: [{
8809
- id: "ha-calendars-import",
8810
- title: "Calendars to import",
8811
- titleKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.title",
8812
- columns: 1,
8813
- fields: [{
8814
- type: "multiselect",
8815
- key: "calendarIds",
8816
- label: "Calendars",
8817
- labelKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.calendarIds.label",
8818
- options: [],
8819
- optionsFrom: "_calendarCatalog",
8820
- description: "Discovered from connected HA brokers. Select at least one to import; leave empty to import none.",
8821
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.calendarIds.description"
8822
- }]
8823
- }] });
9160
+ globalSettingsSchema(_cap) {
8824
9161
  return this.schema({ sections: [{
8825
9162
  id: "ha-broker",
8826
9163
  title: "Home Assistant broker",
@@ -8835,14 +9172,39 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8835
9172
  }]
8836
9173
  }] });
8837
9174
  }
8838
- async getGlobalSettings(overlay, cap, nodeId) {
8839
- if (cap !== "calendar-source") return super.getGlobalSettings(overlay, cap, nodeId);
8840
- const catalog = this.registry ? listHaCalendarCatalog(this.registry) : [];
8841
- return super.getGlobalSettings({
8842
- ...overlay,
8843
- calendarIds: this.config.calendarIds ?? [],
8844
- ...catalog.length > 0 ? { _calendarCatalog: catalog } : {}
8845
- }, cap, nodeId);
9175
+ /**
9176
+ * The calendars of ONE Home Assistant instance (D985): picked on its integration, offered from
9177
+ * that broker's own `calendar.*` entities.
9178
+ */
9179
+ async integrationSettingsSchema(instance) {
9180
+ if (instance.integrationId === null || !this.registry) return null;
9181
+ let brokerId;
9182
+ try {
9183
+ brokerId = (await this.ctx.api.integrations.getSettings.query({ id: instance.integrationId }))["brokerId"];
9184
+ } catch (err) {
9185
+ this.ctx.logger.warn("HA integration form: instance lookup failed — broker form shown", { meta: {
9186
+ integrationId: instance.integrationId,
9187
+ error: errMsg(err)
9188
+ } });
9189
+ return null;
9190
+ }
9191
+ if (typeof brokerId !== "string") return null;
9192
+ const options = listHaCalendarCatalog(this.registry, brokerId);
9193
+ return this.schema({ sections: [{
9194
+ id: "ha-calendars-import",
9195
+ title: "Calendars to import",
9196
+ titleKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.title",
9197
+ columns: 1,
9198
+ fields: [{
9199
+ type: "multiselect",
9200
+ key: "calendarIds",
9201
+ label: "Calendars",
9202
+ labelKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.calendarIds.label",
9203
+ options,
9204
+ description: "Discovered from this Home Assistant instance. Select at least one to import; leave empty to import none.",
9205
+ descriptionKey: "provider-homeassistant:globalSettings.s_ha-calendars-import.calendarIds.description"
9206
+ }]
9207
+ }] });
8846
9208
  }
8847
9209
  async onShutdown() {
8848
9210
  if (this.resyncTimer) {
@@ -8890,11 +9252,8 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8890
9252
  for (const entry of persisted) {
8891
9253
  if (entry.id && liveIds.has(entry.id)) {
8892
9254
  try {
8893
- const settings = entry.auth.kind === "supervisor" ? { supervisorToken: entry.auth.supervisorToken } : {
8894
- baseUrl: entry.auth.baseUrl,
8895
- accessToken: entry.auth.accessToken
8896
- };
8897
- await reg.updateEntry(entry.id, settings);
9255
+ const settings = entryToSettings(entry);
9256
+ if (settings !== null) await reg.updateEntry(entry.id, settings);
8898
9257
  } catch (err) {
8899
9258
  this.ctx.logger.warn("ha onConfigChanged: updateEntry failed", { meta: {
8900
9259
  brokerId: entry.id,
@@ -8904,10 +9263,8 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8904
9263
  continue;
8905
9264
  }
8906
9265
  try {
8907
- const settings = entry.auth.kind === "supervisor" ? { supervisorToken: entry.auth.supervisorToken } : {
8908
- baseUrl: entry.auth.baseUrl,
8909
- accessToken: entry.auth.accessToken
8910
- };
9266
+ const settings = entryToSettings(entry);
9267
+ if (settings === null) continue;
8911
9268
  const created = await reg.createEntry(entry.name, settings);
8912
9269
  if (created.id !== entry.id) {
8913
9270
  const next = persisted.map((b) => b === entry ? {
@@ -8925,21 +9282,59 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8925
9282
  }
8926
9283
  }
8927
9284
  async loadPersistedBrokers() {
8928
- const store = this.brokerStore;
8929
- if (store !== null) {
8930
- const fromRecords = await store.listResolved();
8931
- if (fromRecords.length > 0) return fromRecords;
9285
+ return this.requireBrokerStore().listResolved();
9286
+ }
9287
+ requireInstances() {
9288
+ if (this.instances === null) throw new Error("provider-homeassistant: instances not ready");
9289
+ return this.instances;
9290
+ }
9291
+ /**
9292
+ * After an instance changed (created, edited, switched, deleted) the runtime follows the record:
9293
+ * a missing record removes the manager and its adopted devices, an off or unfilled one stops the
9294
+ * manager but keeps the devices, a filled one starts or re-authenticates it.
9295
+ */
9296
+ async syncBrokerRuntime(id) {
9297
+ const reg = this.requireRegistry();
9298
+ const entry = await this.requireBrokerStore().getById(id);
9299
+ this.activeBrokers = await this.loadPersistedBrokers();
9300
+ if (entry === null) {
9301
+ await reg.removeEntry(id);
9302
+ const deviceRegistry = this.ctx.kernel.deviceRegistry;
9303
+ const devices = this.ctx.kernel.devices;
9304
+ if (deviceRegistry && devices) await cascadeRemoveDevicesForBroker({
9305
+ reg: deviceRegistry,
9306
+ devices,
9307
+ addonId: this.ctx.id,
9308
+ brokerId: id,
9309
+ logger: this.ctx.logger
9310
+ });
9311
+ return;
9312
+ }
9313
+ const settings = entry.enabled === false ? null : entryToSettings(entry);
9314
+ if (settings === null) {
9315
+ await reg.removeEntry(id);
9316
+ return;
8932
9317
  }
8933
- return this.config.brokers;
9318
+ try {
9319
+ if (reg.get(id) !== null) await reg.updateEntry(id, settings);
9320
+ else await reg.createEntry(entry.name, settings, {
9321
+ id,
9322
+ ...entry.integrationId !== void 0 ? { integrationId: entry.integrationId } : {}
9323
+ });
9324
+ } catch (err) {
9325
+ this.ctx.logger.warn("ha instance: could not start the broker", { meta: {
9326
+ brokerId: id,
9327
+ error: errMsg(err)
9328
+ } });
9329
+ }
9330
+ }
9331
+ requireBrokerStore() {
9332
+ if (this.brokerStore === null) throw new Error("provider-homeassistant: broker store not ready");
9333
+ return this.brokerStore;
8934
9334
  }
8935
9335
  async persistBrokerEntries(entries) {
8936
9336
  this.activeBrokers = [...entries];
8937
- const store = this.brokerStore;
8938
- if (store !== null) {
8939
- await store.replaceAll(entries);
8940
- return;
8941
- }
8942
- await this.updateGlobalSettings({ brokers: entries });
9337
+ await this.requireBrokerStore().replaceAll(entries);
8943
9338
  }
8944
9339
  buildBrokerProvider() {
8945
9340
  const ownerAddonId = this.ctx.id;
@@ -9013,7 +9408,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
9013
9408
  getSettings: async ({ id }) => {
9014
9409
  const entry = this.activeBrokers.find((b) => b.id === id);
9015
9410
  if (!entry) return null;
9016
- return authToSettings(entry.auth);
9411
+ return entryToSettings(entry);
9017
9412
  },
9018
9413
  setSettings: async ({ id, settings }) => {
9019
9414
  if (!owns(id)) return;
@@ -9024,7 +9419,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
9024
9419
  getBrokerConfig: async ({ id }) => {
9025
9420
  const entry = this.activeBrokers.find((b) => b.id === id);
9026
9421
  if (!entry) return null;
9027
- return authToSettings(entry.auth);
9422
+ return entryToSettings(entry);
9028
9423
  },
9029
9424
  getSettingsSchema: async ({ kind }) => {
9030
9425
  if (kind !== HA_KIND) return null;