@camstack/addon-provider-homeassistant 1.2.155 → 1.2.157

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 { A as connectivityCapability, At as string, B as floodCapability, C as buildAddonRouteProvider, Ct as _enum, D as carbonMonoxideCapability, Dt as number, E as calendarSourceCapability, Et as literal, F as deviceAdoptionCapability, G as lawnMowerControlCapability, H as humidifierCapability, J as motionCapability, K as lockControlCapability, L as enumSensorCapability, M as controlCapability, Mt as unknown, N as coverCapability, Nt as url, O as climateControlCapability, Ot as object, P as deviceAdminLinkCapability, Pt as EventCategory, Q as numericSensorCapability, R as eventEmitterCapability, S as brokerCapability, T as buttonCapability, Tt as discriminatedUnion, U as humiditySensorCapability, V as gasCapability, W as imageCapability, X as notificationOutputCapability, Y as normalizeUnit, Z as notifierCapability, _ as automationControlCapability, _t as DeviceRole, at as scriptRunnerCapability, b as binaryCapability, ct as tamperCapability, d as TargetSchema, dt as vacuumControlCapability, et as parseAdminBase, f as accessoriesCapability, ft as valveCapability, g as ambientLightSensorCapability, gt as DeviceFeature, h as alarmPanelCapability, ht as weatherCapability, it as pressureSensorCapability, j as contactCapability, jt as union, k as colorCapability, kt as record, lt as temperatureSensorCapability, m as airQualitySensorCapability, mt as waterHeaterCapability, n as BaseDevice, nt as prepareNotification, ot as smokeCapability, p as addonRoutesCapability, pt as vibrationCapability, q as mediaPlayerCapability, r as BaseDeviceProvider, rt as presenceCapability, s as EnumSensorDateTimeFormatSchema, st as switchCapability, tt as powerMeterCapability, ut as updateCapability, v as batteryCapability, vt as DeviceType, w as buildDeviceAdminUrl, wt as array, x as brightnessCapability, xt as createEvent, y as bestLocationMatch, yt as errMsg, z as fanControlCapability } from "./dist-DqKiElqb.mjs";
1
+ import { A as connectivityCapability, At as record, B as floodCapability, C as buildAddonRouteProvider, Ct as _enum, D as carbonMonoxideCapability, Dt as literal, E as calendarSourceCapability, Et as discriminatedUnion, F as deviceAdoptionCapability, Ft as EventCategory, G as lawnMowerControlCapability, H as humidifierCapability, J as motionCapability, K as lockControlCapability, L as enumSensorCapability, M as controlCapability, Mt as union, N as coverCapability, Nt as unknown, O as climateControlCapability, Ot as number, P as deviceAdminLinkCapability, Pt as url, Q as numericSensorCapability, R as eventEmitterCapability, S as brokerCapability, T as buttonCapability, Tt as boolean, U as humiditySensorCapability, V as gasCapability, W as imageCapability, X as notificationOutputCapability, Y as normalizeUnit, Z as notifierCapability, _ as automationControlCapability, _t as DeviceRole, at as scriptRunnerCapability, b as binaryCapability, ct as tamperCapability, d as TargetSchema, dt as vacuumControlCapability, et as parseAdminBase, f as accessoriesCapability, ft as valveCapability, g as ambientLightSensorCapability, gt as DeviceFeature, h as alarmPanelCapability, ht as weatherCapability, it as pressureSensorCapability, j as contactCapability, jt as string, k as colorCapability, kt as object, lt as temperatureSensorCapability, m as airQualitySensorCapability, mt as waterHeaterCapability, n as BaseDevice, nt as prepareNotification, ot as smokeCapability, p as addonRoutesCapability, pt as vibrationCapability, q as mediaPlayerCapability, r as BaseDeviceProvider, rt as presenceCapability, s as EnumSensorDateTimeFormatSchema, st as switchCapability, tt as powerMeterCapability, ut as updateCapability, v as batteryCapability, vt as DeviceType, w as buildDeviceAdminUrl, wt as array, x as brightnessCapability, xt as createEvent, y as bestLocationMatch, yt as errMsg, z as fanControlCapability } from "./dist-BgTykMb6.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
@@ -729,12 +729,16 @@ var HaSwitchDevice = class extends HaBaseChildDevice {
729
729
  constructor(ctx) {
730
730
  super(ctx);
731
731
  this.registerSwitchCap();
732
+ const stored = this.runtimeState.getCapState("switch");
733
+ const seededOn = typeof stored?.on === "boolean" ? stored.on : false;
734
+ const seededChangedAt = typeof stored?.lastChangedAt === "number" && Number.isFinite(stored.lastChangedAt) && stored.lastChangedAt > 0 ? stored.lastChangedAt : 0;
732
735
  this.runtimeState.setCapState("switch", {
733
- on: false,
734
- lastChangedAt: 0
736
+ on: seededOn,
737
+ lastChangedAt: seededChangedAt
735
738
  });
736
739
  }
737
740
  handleStatePush(state) {
741
+ if (state.state === "unavailable" || state.state === "unknown") return;
738
742
  const on = state.state === "on";
739
743
  const lastChangedAt = Date.parse(state.last_changed) || Date.now();
740
744
  const previous = this.runtimeState.getCapState("switch");
@@ -5137,7 +5141,7 @@ var HaIntegrationManager = class HaIntegrationManager {
5137
5141
  service: target.service
5138
5142
  };
5139
5143
  const targetEnvelope = {};
5140
- if (target.entityId) targetEnvelope["entity_id"] = target.entityId;
5144
+ if (target.entityId !== void 0) targetEnvelope["entity_id"] = Array.isArray(target.entityId) ? [...target.entityId] : target.entityId;
5141
5145
  if (target.areaId) targetEnvelope["area_id"] = target.areaId;
5142
5146
  if (target.deviceId) targetEnvelope["device_id"] = target.deviceId;
5143
5147
  if (Object.keys(targetEnvelope).length > 0) message["target"] = targetEnvelope;
@@ -6040,6 +6044,12 @@ function mapImage(_attributes) {
6040
6044
  capabilities: ["image"]
6041
6045
  };
6042
6046
  }
6047
+ /** True when HA `unit_of_measurement` clearly means temperature (°C / °F). */
6048
+ function isTemperatureUnit(unit) {
6049
+ if (typeof unit !== "string") return false;
6050
+ const n = unit.trim().toLowerCase().replace(/\s+/g, "");
6051
+ return n === "°c" || n === "°f" || n === "℃" || n === "℉" || n === "c" || n === "f" || n === "celsius" || n === "fahrenheit";
6052
+ }
6043
6053
  function mapNumericSensor(deviceClass, _attributes) {
6044
6054
  switch (deviceClass) {
6045
6055
  case "temperature": return {
@@ -6135,11 +6145,17 @@ function mapNumericSensor(deviceClass, _attributes) {
6135
6145
  capabilities: ["enum-sensor"],
6136
6146
  dateTimeFormat: "date"
6137
6147
  };
6138
- case null: return {
6139
- deviceType: DeviceType.Sensor,
6140
- role: DeviceRole.EnumSensor,
6141
- capabilities: ["enum-sensor"]
6142
- };
6148
+ case null:
6149
+ if (isTemperatureUnit(_attributes["unit_of_measurement"])) return {
6150
+ deviceType: DeviceType.Sensor,
6151
+ role: DeviceRole.TemperatureSensor,
6152
+ capabilities: ["temperature-sensor"]
6153
+ };
6154
+ return {
6155
+ deviceType: DeviceType.Sensor,
6156
+ role: DeviceRole.EnumSensor,
6157
+ capabilities: ["enum-sensor"]
6158
+ };
6143
6159
  default: return {
6144
6160
  deviceType: DeviceType.Sensor,
6145
6161
  role: DeviceRole.NumericSensor,
@@ -6444,6 +6460,122 @@ function buildDiscoveryCandidates(input) {
6444
6460
  return candidates;
6445
6461
  }
6446
6462
  //#endregion
6463
+ //#region src/ha-discovery-types.ts
6464
+ /**
6465
+ * HA-specific discovery data types — addon-internal.
6466
+ *
6467
+ * These shapes were formerly the `ha-discovery` cap's data schemas.
6468
+ * That cap was removed in favour of the generic `device-adoption`
6469
+ * cap, so these types are no longer a public cap surface — they live
6470
+ * here as the HA addon's own discovery model. The `HaDiscoveryService`
6471
+ * produces them and `haDeviceToCandidate` adapts an `HaDiscoveredDevice`
6472
+ * onto the shared `DiscoveredChildDevice` the `device-adoption` cap
6473
+ * surfaces.
6474
+ */
6475
+ /**
6476
+ * Synthetic `haDeviceId` bucket for `entity_registry` rows with
6477
+ * `device_id === null` (helpers, template sensors, `daily` statistics, …).
6478
+ * Flattened into the Entities adoption tab / standalone reconcile; never
6479
+ * shown as a Devices-tab candidate.
6480
+ */
6481
+ var HA_ORPHAN_ENTITIES_DEVICE_ID = "__ha_orphan_entities__";
6482
+ var HaEntityMappingSchema = object({
6483
+ /** CamStack DeviceType for the accessory child. */
6484
+ deviceType: _enum(DeviceType),
6485
+ /** Optional CamStack DeviceRole sub-role (e.g. `ContactSensor` for
6486
+ * a binary_sensor with `device_class: door`). */
6487
+ role: _enum(DeviceRole).nullable(),
6488
+ /** Capability names the accessory child will register
6489
+ * (`switch` / `temperature-sensor` / …). UI may render the cap
6490
+ * badges to help the operator decide what to hide. */
6491
+ capabilities: array(string()).readonly(),
6492
+ /** Mapping-derived DeviceFeature flags (e.g. `lock-open`). Persisted onto
6493
+ * the child's `device.features` so the UI gates feature affordances. */
6494
+ features: array(string()).readonly().optional(),
6495
+ /** Locale-render shape for a `DateTimeSensor`-role enum sensor
6496
+ * (`device_class=timestamp` → `'datetime'`, `date` → `'date'`). Threaded
6497
+ * onto the child config so the enum-sensor device seeds it into the cap
6498
+ * slice. Absent for every non-DateTimeSensor mapping. */
6499
+ dateTimeFormat: EnumSensorDateTimeFormatSchema.optional()
6500
+ }).nullable();
6501
+ var HaDiscoveredEntitySchema = object({
6502
+ /** Fully-qualified HA entity id (`switch.kitchen`). */
6503
+ entityId: string(),
6504
+ /** HA domain — first segment of entity_id (`switch` / `light` /
6505
+ * `sensor` / `binary_sensor` / `cover` / …). */
6506
+ domain: string(),
6507
+ /** Operator-visible friendly name (HA `friendly_name` attribute or
6508
+ * the entity_id when missing). */
6509
+ name: string(),
6510
+ /** Current state value (e.g. `'on'`, `'21.5'`, `'open'`). */
6511
+ state: string(),
6512
+ /** HA `device_class` attribute when present — drives the role +
6513
+ * cap mapping for sensors / binary_sensors / covers. */
6514
+ deviceClass: string().nullable(),
6515
+ /** HA `unit_of_measurement` attribute when present. */
6516
+ unitOfMeasurement: string().nullable(),
6517
+ /** Area name for this entity — the entity's own HA area if set, else its
6518
+ * parent device's area. Null when neither is assigned. */
6519
+ area: string().nullable().default(null),
6520
+ /** HA `options` attribute — present on `select` / `input_select` entities.
6521
+ * Captured at discovery so the adopt handler can persist them alongside
6522
+ * other entity config fields. Absent on non-select entities. */
6523
+ options: array(string()).nullable().default(null),
6524
+ /** HA `entity_category` — `config` / `diagnostic` / null (a normal control).
6525
+ * Drives the accordion section the adopted child renders in. Null/absent for
6526
+ * the common control case. */
6527
+ entityCategory: _enum(["config", "diagnostic"]).nullable().default(null),
6528
+ /** Pre-computed CamStack mapping. `null` when the entity's domain
6529
+ * has no supported mapping (the operator may still adopt the
6530
+ * parent — the entity will be skipped or surfaced as a fallback). */
6531
+ mappedTo: HaEntityMappingSchema,
6532
+ /** When `true`, the entity was already materialised as a CamStack
6533
+ * accessory child under a prior adoption — surfaced so the UI can
6534
+ * highlight existing wiring. */
6535
+ alreadyAdopted: boolean(),
6536
+ /** When `alreadyAdopted === true`, the kernel-assigned accessory
6537
+ * child id. */
6538
+ adoptedCamDeviceId: number().int().nonnegative().nullable()
6539
+ });
6540
+ object({
6541
+ /** Stable HA `device_registry` id — survives renames + restarts. */
6542
+ haDeviceId: string(),
6543
+ /** Operator-visible name (HA device `name` or first entity's
6544
+ * friendly_name when device has none). */
6545
+ name: string(),
6546
+ /** Area name from HA `area_registry` join. Null when unassigned. */
6547
+ area: string().nullable(),
6548
+ manufacturer: string().nullable(),
6549
+ model: string().nullable(),
6550
+ swVersion: string().nullable(),
6551
+ /** Dominant integration / platform domain for this device, derived from its
6552
+ * entities' HA `entity_registry.platform` (e.g. `dreame_vacuum`). Generic /
6553
+ * helper platforms (`unifi` / `hacs` / `hassio`) are excluded when a real
6554
+ * platform is available. Null when the device has no entities. */
6555
+ integration: string().nullable().default(null),
6556
+ /** Convenience field — count of `entities[]`. */
6557
+ entityCount: number().int().nonnegative(),
6558
+ /** Every entity HA's `entity_registry` links to this device. The
6559
+ * list survives row collapse — full devices are returned so
6560
+ * accordion expansion is a no-roundtrip render. */
6561
+ entities: array(HaDiscoveredEntitySchema).readonly(),
6562
+ /** Whether this HA device has an active CamStack adoption. */
6563
+ alreadyAdopted: boolean(),
6564
+ /** When `alreadyAdopted === true`, the kernel-assigned parent id. */
6565
+ adoptedCamDeviceId: number().int().nonnegative().nullable()
6566
+ });
6567
+ object({
6568
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
6569
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
6570
+ /** Count of HA devices in the discovery cache (across all brokers). */
6571
+ deviceCount: number().int().nonnegative(),
6572
+ /** Count of HA devices currently materialised as CamStack parent
6573
+ * devices. */
6574
+ adoptedCount: number().int().nonnegative(),
6575
+ /** Last error message from a refresh attempt. */
6576
+ lastError: string().nullable()
6577
+ });
6578
+ //#endregion
6447
6579
  //#region src/ha-integration-domain.ts
6448
6580
  /**
6449
6581
  * Derive the dominant integration / platform domain for an HA device from
@@ -7046,6 +7178,11 @@ function createHaNotificationOutputProvider(deps) {
7046
7178
  }
7047
7179
  return out;
7048
7180
  },
7181
+ probeTargetConfig: async ({ kind }) => ({
7182
+ ok: false,
7183
+ resolvedUrl: "",
7184
+ error: `Kind '${kind}' does not support credential probing`
7185
+ }),
7049
7186
  send: async ({ targetId, notification }) => {
7050
7187
  const target = await store.getById(targetId);
7051
7188
  if (!target) return {
@@ -7093,9 +7230,29 @@ function calendarName(state) {
7093
7230
  if (typeof friendly === "string" && friendly.trim().length > 0) return friendly.trim();
7094
7231
  return state.entity_id.replace(/^calendar\./, "").replace(/_/g, " ");
7095
7232
  }
7233
+ /** HA errors from `sendMessagePromise` are plain objects, not `Error`. */
7234
+ function formatHaError(err) {
7235
+ if (err instanceof Error) return err.message;
7236
+ if (typeof err === "string") return err;
7237
+ if (typeof err === "object" && err !== null) {
7238
+ const rec = err;
7239
+ const message = rec["message"];
7240
+ if (typeof message === "string" && message.length > 0) {
7241
+ const code = rec["code"];
7242
+ return typeof code === "string" || typeof code === "number" ? `${String(code)}: ${message}` : message;
7243
+ }
7244
+ try {
7245
+ return JSON.stringify(err);
7246
+ } catch {
7247
+ return Object.prototype.toString.call(err);
7248
+ }
7249
+ }
7250
+ return String(err);
7251
+ }
7096
7252
  function parseHaDate(value) {
7097
7253
  if (typeof value !== "string" || value.length === 0) return null;
7098
- const ms = Date.parse(value.length === 10 ? `${value}T00:00:00` : value);
7254
+ const normalised = value.length === 10 ? `${value}T00:00:00` : value.includes("T") ? value : value.replace(" ", "T");
7255
+ const ms = Date.parse(normalised);
7099
7256
  return Number.isFinite(ms) ? ms : null;
7100
7257
  }
7101
7258
  function isRecord(x) {
@@ -7106,17 +7263,17 @@ function asEntityState(raw) {
7106
7263
  return parsed.success ? parsed.data : null;
7107
7264
  }
7108
7265
  /**
7109
- * HA's `calendar.list_events` response shape (return_response):
7110
- * `{ response: { [entity_id]: { events: [...] } } }` or a flat
7111
- * `{ events: [...] }` depending on target width.
7266
+ * HA's `calendar.get_events` response shape (return_response):
7267
+ * `{ response: { [entity_id]: { events: [...] } } }` — also tolerates a
7268
+ * bare `{ [entity_id]: { events } }` or a legacy flat `{ events: [...] }`.
7112
7269
  */
7113
7270
  function extractEvents(result, entityId) {
7114
7271
  if (!isRecord(result)) return [];
7115
- const response = result["response"];
7272
+ const response = isRecord(result["response"]) ? result["response"] : result;
7116
7273
  if (isRecord(response)) {
7117
7274
  const forEntity = response[entityId];
7118
7275
  if (isRecord(forEntity) && Array.isArray(forEntity["events"])) return forEntity["events"].filter(isRecord);
7119
- for (const value of Object.values(response)) if (isRecord(value) && Array.isArray(value["events"])) return value["events"].filter(isRecord);
7276
+ if (entityId in response) return [];
7120
7277
  }
7121
7278
  if (Array.isArray(result["events"])) return result["events"].filter(isRecord);
7122
7279
  return [];
@@ -7144,6 +7301,29 @@ function mapEvent(entityId, raw, index) {
7144
7301
  ...typeof location === "string" && location.length > 0 ? { location } : {}
7145
7302
  };
7146
7303
  }
7304
+ /** One `get_events` call can target many calendars (HA keys the response). */
7305
+ async function fetchEventsForEntities(manager, entityIds, startIso, endIso) {
7306
+ if (entityIds.length === 0) return [];
7307
+ const target = {
7308
+ domain: "calendar",
7309
+ service: "get_events",
7310
+ entityId: entityIds.length === 1 ? entityIds[0] : entityIds,
7311
+ returnResponse: true
7312
+ };
7313
+ const result = await manager.publish(target, {
7314
+ start_date_time: startIso,
7315
+ end_date_time: endIso
7316
+ });
7317
+ const out = [];
7318
+ for (const entityId of entityIds) {
7319
+ const rawEvents = extractEvents(result, entityId);
7320
+ for (let i = 0; i < rawEvents.length; i += 1) {
7321
+ const mapped = mapEvent(entityId, rawEvents[i], i);
7322
+ if (mapped !== null) out.push(mapped);
7323
+ }
7324
+ }
7325
+ return out;
7326
+ }
7147
7327
  function createHaCalendarSource(deps) {
7148
7328
  return {
7149
7329
  listCalendars: async () => {
@@ -7172,26 +7352,15 @@ function createHaCalendarSource(deps) {
7172
7352
  for (const { brokerId, manager } of deps.brokers.listManagers()) {
7173
7353
  const states = manager.snapshotAllStates();
7174
7354
  const entityIds = Object.keys(states).filter((id) => isCalendarEntity(id) && (wanted === null || wanted.has(id)));
7175
- for (const entityId of entityIds) try {
7176
- const target = {
7177
- domain: "calendar",
7178
- service: "list_events",
7179
- entityId,
7180
- returnResponse: true
7181
- };
7182
- const rawEvents = extractEvents(await manager.publish(target, {
7183
- start_date_time: startIso,
7184
- end_date_time: endIso
7185
- }), entityId);
7186
- for (let i = 0; i < rawEvents.length; i += 1) {
7187
- const mapped = mapEvent(entityId, rawEvents[i], i);
7188
- if (mapped !== null) out.push(mapped);
7189
- }
7355
+ if (entityIds.length === 0) continue;
7356
+ try {
7357
+ const events = await fetchEventsForEntities(manager, entityIds, startIso, endIso);
7358
+ for (const ev of events) out.push(ev);
7190
7359
  } catch (err) {
7191
- deps.logger.warn("HA calendar.list_events failed", { meta: {
7192
- entityId,
7360
+ deps.logger.warn("HA calendar.get_events failed", { meta: {
7361
+ entityIds,
7193
7362
  brokerId,
7194
- error: err instanceof Error ? err.message : String(err)
7363
+ error: formatHaError(err)
7195
7364
  } });
7196
7365
  }
7197
7366
  }
@@ -8074,7 +8243,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8074
8243
  let lastError = null;
8075
8244
  for (const { brokerId } of managers) try {
8076
8245
  const devices = await this.fetchHaDiscoveredDevices(brokerId);
8077
- candidateCount += devices.length;
8246
+ candidateCount += devices.filter((d) => d.haDeviceId !== HA_ORPHAN_ENTITIES_DEVICE_ID).length;
8078
8247
  const refreshedAt = Date.now();
8079
8248
  if (lastDiscoveryAt === null || refreshedAt > lastDiscoveryAt) lastDiscoveryAt = refreshedAt;
8080
8249
  } catch (err) {
@@ -8094,7 +8263,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8094
8263
  const adoptedCount = (await this.ctx.kernel.devices.getAll()).filter((d) => d.parentDeviceId === null && String(d.config.get("system") ?? "") === "homeassistant").length;
8095
8264
  return {
8096
8265
  lastDiscoveryAt: Date.now(),
8097
- candidateCount: devices.length,
8266
+ candidateCount: devices.filter((d) => d.haDeviceId !== HA_ORPHAN_ENTITIES_DEVICE_ID).length,
8098
8267
  adoptedCount,
8099
8268
  lastError: null
8100
8269
  };
@@ -8123,6 +8292,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8123
8292
  if (typeof haDeviceId === "string") adoptedHaDeviceIds.add(haDeviceId);
8124
8293
  }
8125
8294
  let haDevices = await this.fetchHaDiscoveredDevices(brokerId);
8295
+ haDevices = haDevices.filter((d) => d.haDeviceId !== HA_ORPHAN_ENTITIES_DEVICE_ID);
8126
8296
  haDevices = haDevices.map((dev) => ({
8127
8297
  ...dev,
8128
8298
  alreadyAdopted: adoptedHaDeviceIds.has(dev.haDeviceId)
@@ -8159,7 +8329,9 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8159
8329
  const haDeviceId = d.config.get("haDeviceId");
8160
8330
  if (typeof haDeviceId === "string") adoptedHaDeviceIds.add(haDeviceId);
8161
8331
  }
8162
- const found = (await this.fetchHaDiscoveredDevices(brokerId)).find((d) => d.haDeviceId === childNativeId) ?? null;
8332
+ const haDevices = await this.fetchHaDiscoveredDevices(brokerId);
8333
+ if (childNativeId === "__ha_orphan_entities__") return null;
8334
+ const found = haDevices.find((d) => d.haDeviceId === childNativeId) ?? null;
8163
8335
  if (!found) return null;
8164
8336
  return haDeviceToCandidate({
8165
8337
  ...found,
@@ -8187,6 +8359,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8187
8359
  let failures = 0;
8188
8360
  let reused = 0;
8189
8361
  for (const haDeviceId of childNativeIds) try {
8362
+ if (haDeviceId === "__ha_orphan_entities__") throw new Error("orphan entity bucket is not adoptable as a device — use the Entities tab");
8190
8363
  const haDevice = haDeviceMap.get(haDeviceId);
8191
8364
  const deviceName = (perCandidate?.[haDeviceId])?.name ?? haDevice?.name ?? haDeviceId;
8192
8365
  const existing = existingByStableId.get(`ha:${brokerId}:dev:${haDeviceId}`);
@@ -8444,6 +8617,11 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8444
8617
  * it to the addon-internal `HaDiscoveredDevice[]` shape. Shared between
8445
8618
  * `discoverDevices` (device-provider) and the `device-adoption` handlers.
8446
8619
  *
8620
+ * Entities with `device_id === null` (helpers / template / daily stats) are
8621
+ * collected under a synthetic {@link HA_ORPHAN_ENTITIES_DEVICE_ID} bucket so
8622
+ * the Entities adoption tab + standalone reconcile can see them. Callers on
8623
+ * the Devices path must filter that bucket out.
8624
+ *
8447
8625
  * Unlike the old `HaDiscoveryService`, this is stateless — every call
8448
8626
  * goes to the live WS; caching is intentionally omitted to keep the
8449
8627
  * adoption path simple (the modal fetches once per session).
@@ -8464,15 +8642,59 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8464
8642
  if (areaId && name) areaById.set(areaId, name);
8465
8643
  }
8466
8644
  const entitiesByDevice = /* @__PURE__ */ new Map();
8645
+ const orphanRaws = [];
8467
8646
  for (const raw of rawEntities) {
8468
8647
  const entityId = typeof raw["entity_id"] === "string" ? raw["entity_id"] : null;
8469
8648
  const deviceId = typeof raw["device_id"] === "string" ? raw["device_id"] : null;
8470
8649
  const disabledBy = raw["disabled_by"] !== null && raw["disabled_by"] !== void 0 ? String(raw["disabled_by"]) : null;
8471
- if (!entityId || !deviceId || disabledBy !== null) continue;
8650
+ if (!entityId || disabledBy !== null) continue;
8651
+ if (!deviceId) {
8652
+ orphanRaws.push(raw);
8653
+ continue;
8654
+ }
8472
8655
  const bucket = entitiesByDevice.get(deviceId);
8473
8656
  if (bucket) bucket.push(raw);
8474
8657
  else entitiesByDevice.set(deviceId, [raw]);
8475
8658
  }
8659
+ const mapRawEntity = (raw, deviceAreaName) => {
8660
+ const entityId = raw["entity_id"];
8661
+ const domain = entityId.split(".")[0] ?? entityId;
8662
+ const stateEntry = stateSnapshot[entityId];
8663
+ const attributes = stateEntry?.attributes ?? {};
8664
+ const deviceClass = typeof raw["device_class"] === "string" ? raw["device_class"] : typeof raw["original_device_class"] === "string" ? raw["original_device_class"] : typeof attributes["device_class"] === "string" ? attributes["device_class"] : null;
8665
+ const unit = typeof attributes["unit_of_measurement"] === "string" ? attributes["unit_of_measurement"] : null;
8666
+ const rawOptions = attributes["options"];
8667
+ const options = Array.isArray(rawOptions) && rawOptions.length > 0 ? rawOptions.filter((x) => typeof x === "string") : null;
8668
+ const entityAreaId = typeof raw["area_id"] === "string" ? raw["area_id"] : null;
8669
+ const entityArea = entityAreaId ? areaById.get(entityAreaId) ?? deviceAreaName : deviceAreaName;
8670
+ const rawCategory = raw["entity_category"];
8671
+ const entityCategory = rawCategory === "config" || rawCategory === "diagnostic" ? rawCategory : null;
8672
+ const mapping = mapHaEntityToCamStack({
8673
+ domain,
8674
+ deviceClass,
8675
+ attributes
8676
+ });
8677
+ return {
8678
+ entityId,
8679
+ domain,
8680
+ name: typeof raw["name"] === "string" && raw["name"] ? raw["name"] : typeof raw["original_name"] === "string" && raw["original_name"] ? raw["original_name"] : (typeof attributes["friendly_name"] === "string" ? attributes["friendly_name"] : null) ?? entityId,
8681
+ state: stateEntry?.state ?? "unknown",
8682
+ deviceClass,
8683
+ unitOfMeasurement: unit,
8684
+ area: entityArea,
8685
+ options,
8686
+ entityCategory,
8687
+ mappedTo: mapping ? {
8688
+ deviceType: mapping.deviceType,
8689
+ role: mapping.role,
8690
+ capabilities: [...mapping.capabilities],
8691
+ features: mapping.features ? [...mapping.features] : [],
8692
+ ...mapping.dateTimeFormat !== void 0 ? { dateTimeFormat: mapping.dateTimeFormat } : {}
8693
+ } : null,
8694
+ alreadyAdopted: false,
8695
+ adoptedCamDeviceId: null
8696
+ };
8697
+ };
8476
8698
  const out = [];
8477
8699
  for (const rawDev of rawDevices) {
8478
8700
  const devId = typeof rawDev["id"] === "string" ? rawDev["id"] : null;
@@ -8481,45 +8703,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8481
8703
  const areaIdRaw = typeof rawDev["area_id"] === "string" ? rawDev["area_id"] : null;
8482
8704
  const deviceAreaName = areaIdRaw ? areaById.get(areaIdRaw) ?? null : null;
8483
8705
  const entBucket = entitiesByDevice.get(devId) ?? [];
8484
- const entities = entBucket.map((raw) => {
8485
- const entityId = raw["entity_id"];
8486
- const domain = entityId.split(".")[0] ?? entityId;
8487
- const stateEntry = stateSnapshot[entityId];
8488
- const attributes = stateEntry?.attributes ?? {};
8489
- const deviceClass = typeof raw["device_class"] === "string" ? raw["device_class"] : typeof raw["original_device_class"] === "string" ? raw["original_device_class"] : typeof attributes["device_class"] === "string" ? attributes["device_class"] : null;
8490
- const unit = typeof attributes["unit_of_measurement"] === "string" ? attributes["unit_of_measurement"] : null;
8491
- const rawOptions = attributes["options"];
8492
- const options = Array.isArray(rawOptions) && rawOptions.length > 0 ? rawOptions.filter((x) => typeof x === "string") : null;
8493
- const entityAreaId = typeof raw["area_id"] === "string" ? raw["area_id"] : null;
8494
- const entityArea = entityAreaId ? areaById.get(entityAreaId) ?? deviceAreaName : deviceAreaName;
8495
- const rawCategory = raw["entity_category"];
8496
- const entityCategory = rawCategory === "config" || rawCategory === "diagnostic" ? rawCategory : null;
8497
- const mapping = mapHaEntityToCamStack({
8498
- domain,
8499
- deviceClass,
8500
- attributes
8501
- });
8502
- return {
8503
- entityId,
8504
- domain,
8505
- name: typeof raw["name"] === "string" && raw["name"] ? raw["name"] : typeof raw["original_name"] === "string" && raw["original_name"] ? raw["original_name"] : (typeof attributes["friendly_name"] === "string" ? attributes["friendly_name"] : null) ?? entityId,
8506
- state: stateEntry?.state ?? "unknown",
8507
- deviceClass,
8508
- unitOfMeasurement: unit,
8509
- area: entityArea,
8510
- options,
8511
- entityCategory,
8512
- mappedTo: mapping ? {
8513
- deviceType: mapping.deviceType,
8514
- role: mapping.role,
8515
- capabilities: [...mapping.capabilities],
8516
- features: mapping.features ? [...mapping.features] : [],
8517
- ...mapping.dateTimeFormat !== void 0 ? { dateTimeFormat: mapping.dateTimeFormat } : {}
8518
- } : null,
8519
- alreadyAdopted: false,
8520
- adoptedCamDeviceId: null
8521
- };
8522
- });
8706
+ const entities = entBucket.map((raw) => mapRawEntity(raw, deviceAreaName));
8523
8707
  const devName = typeof rawDev["name_by_user"] === "string" && rawDev["name_by_user"] ? rawDev["name_by_user"] : typeof rawDev["name"] === "string" && rawDev["name"] ? rawDev["name"] : entBucket[0] ? String(entBucket[0]["name"] ?? entBucket[0]["original_name"] ?? devId) : devId;
8524
8708
  const integration = deriveIntegrationDomain(entBucket.map((raw) => typeof raw["platform"] === "string" ? raw["platform"] : null));
8525
8709
  out.push({
@@ -8536,6 +8720,23 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
8536
8720
  adoptedCamDeviceId: null
8537
8721
  });
8538
8722
  }
8723
+ if (orphanRaws.length > 0) {
8724
+ const entities = orphanRaws.map((raw) => mapRawEntity(raw, null));
8725
+ const entityPlatforms = orphanRaws.map((raw) => typeof raw["platform"] === "string" ? raw["platform"] : null);
8726
+ out.push({
8727
+ haDeviceId: HA_ORPHAN_ENTITIES_DEVICE_ID,
8728
+ name: "Standalone entities",
8729
+ area: null,
8730
+ manufacturer: null,
8731
+ model: null,
8732
+ swVersion: null,
8733
+ integration: deriveIntegrationDomain(entityPlatforms),
8734
+ entityCount: entities.length,
8735
+ entities,
8736
+ alreadyAdopted: false,
8737
+ adoptedCamDeviceId: null
8738
+ });
8739
+ }
8539
8740
  return out;
8540
8741
  }
8541
8742
  /**