@camstack/addon-provider-homeassistant 1.2.78 → 1.2.79

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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dist = require("./dist-gv0Gmrm1.js");
2
+ const require_dist = require("./dist-BI0EGQ5s.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let node_zlib = require("node:zlib");
5
5
  //#region src/ha-device-source.ts
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as presenceCapability, A as controlCapability, At as EventCategory, B as humiditySensorCapability, C as buildAddonRouteProvider, Ct as number, D as colorCapability, Dt as union, E as climateControlCapability, Et as string, F as eventEmitterCapability, G as motionCapability, H as lawnMowerControlCapability, I as fanControlCapability, J as notifierCapability, K as normalizeUnit, L as floodCapability, M as deviceAdoptionCapability, O as connectivityCapability, Ot as unknown, P as enumSensorCapability, Q as prepareNotification, R as gasCapability, S as brokerCapability, St as literal, T as carbonMonoxideCapability, Tt as record, U as lockControlCapability, V as imageCapability, W as mediaPlayerCapability, Y as numericSensorCapability, Z as powerMeterCapability, _ as automationControlCapability, _t as createEvent, at as temperatureSensorCapability, b as binaryCapability, bt as array, ct as valveCapability, d as TargetSchema, dt as weatherCapability, et as pressureSensorCapability, f as accessoriesCapability, ft as errMsg, g as ambientLightSensorCapability, gt as DeviceType, h as alarmPanelCapability, ht as DeviceRole, it as tamperCapability, j as coverCapability, k as contactCapability, kt as url, lt as vibrationCapability, m as airQualitySensorCapability, mt as DeviceFeature, n as BaseDevice, nt as smokeCapability, ot as updateCapability, p as addonRoutesCapability, q as notificationOutputCapability, r as BaseDeviceProvider, rt as switchCapability, s as EnumSensorDateTimeFormatSchema, st as vacuumControlCapability, tt as scriptRunnerCapability, ut as waterHeaterCapability, v as batteryCapability, w as buttonCapability, wt as object, x as brightnessCapability, xt as discriminatedUnion, y as bestLocationMatch, yt as _enum, z as humidifierCapability } from "./dist-CQzCvoCx.mjs";
1
+ import { $ as presenceCapability, A as controlCapability, At as EventCategory, B as humiditySensorCapability, C as buildAddonRouteProvider, Ct as number, D as colorCapability, Dt as union, E as climateControlCapability, Et as string, F as eventEmitterCapability, G as motionCapability, H as lawnMowerControlCapability, I as fanControlCapability, J as notifierCapability, K as normalizeUnit, L as floodCapability, M as deviceAdoptionCapability, O as connectivityCapability, Ot as unknown, P as enumSensorCapability, Q as prepareNotification, R as gasCapability, S as brokerCapability, St as literal, T as carbonMonoxideCapability, Tt as record, U as lockControlCapability, V as imageCapability, W as mediaPlayerCapability, Y as numericSensorCapability, Z as powerMeterCapability, _ as automationControlCapability, _t as createEvent, at as temperatureSensorCapability, b as binaryCapability, bt as array, ct as valveCapability, d as TargetSchema, dt as weatherCapability, et as pressureSensorCapability, f as accessoriesCapability, ft as errMsg, g as ambientLightSensorCapability, gt as DeviceType, h as alarmPanelCapability, ht as DeviceRole, it as tamperCapability, j as coverCapability, k as contactCapability, kt as url, lt as vibrationCapability, m as airQualitySensorCapability, mt as DeviceFeature, n as BaseDevice, nt as smokeCapability, ot as updateCapability, p as addonRoutesCapability, q as notificationOutputCapability, r as BaseDeviceProvider, rt as switchCapability, s as EnumSensorDateTimeFormatSchema, st as vacuumControlCapability, tt as scriptRunnerCapability, ut as waterHeaterCapability, v as batteryCapability, w as buttonCapability, wt as object, x as brightnessCapability, xt as discriminatedUnion, y as bestLocationMatch, yt as _enum, z as humidifierCapability } from "./dist-CXuG5e6V.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-device-source.ts
@@ -13285,6 +13285,24 @@ var deviceProviderCapability = {
13285
13285
  })
13286
13286
  }
13287
13287
  };
13288
+ /**
13289
+ * Device Manager capability — hub-side singleton that unifies device persistence,
13290
+ * live registry access, and all management operations into a single tRPC surface.
13291
+ *
13292
+ * Replaces:
13293
+ * - `device-persistence` capability (persistence methods absorbed here)
13294
+ * - `device-management.router.ts` (deleted in Phase 2)
13295
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
13296
+ *
13297
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
13298
+ * fork into separate processes but never run on remote cluster agents. Therefore:
13299
+ * - No nodeId routing needed — this is a pure hub singleton.
13300
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
13301
+ * - No shadow registry or cross-node aggregation required.
13302
+ *
13303
+ * Forked workers register devices back to the hub via `ctx.devices`
13304
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
13305
+ */
13288
13306
  /** One child-placement directive on a container's `childLayout`. Structurally
13289
13307
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
13290
13308
  * shape for the same field. The child is identified by its re-sync-stable
@@ -19041,6 +19059,9 @@ var RetrainStatusSchema = _enum([
19041
19059
  *
19042
19060
  * `debug` does NOT pin; it is attention, not durability.
19043
19061
  *
19062
+ * `debugNote` is the operator's own words about WHAT should be checked on this
19063
+ * track, and it lives and dies with `debug` — see {@link MAX_TRACK_DEBUG_NOTE_LEN}.
19064
+ *
19044
19065
  * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
19045
19066
  * A favourited track is skipped by retention the same way `staging` is, but
19046
19067
  * it does not enter `none|staging|trained` and has no staging budget.
@@ -19051,6 +19072,21 @@ var TrackFlagFields = {
19051
19072
  markForTrain: boolean().optional(),
19052
19073
  /** Operator marked this track for diagnostic attention. */
19053
19074
  debug: boolean().optional(),
19075
+ /**
19076
+ * What the operator wants CHECKED on this track, in their own words.
19077
+ *
19078
+ * The flag alone says "look at this" and nothing about what for; a debug set
19079
+ * reviewed hours later is a list of tracks with no question attached. Bound
19080
+ * to `debug` on the WRITE side — the body clears it when `debug` goes off,
19081
+ * and refuses it on a track whose debug is off — so a note can never outlive
19082
+ * the attention it belongs to.
19083
+ *
19084
+ * `''` is a real value ("marked, nothing to add"); the ABSENT key means
19085
+ * "leave whatever is stored alone", which is what lets a surface turn debug
19086
+ * on without a note (a cancelled prompt) and what lets it edit the note
19087
+ * without touching the flag.
19088
+ */
19089
+ debugNote: string().max(500).optional(),
19054
19090
  /** Operator favourited this track. Pins it against pruning. */
19055
19091
  favourited: boolean().optional()
19056
19092
  };
@@ -19077,6 +19113,12 @@ var TrackFlagsSchema = object({
19077
19113
  trackId: string(),
19078
19114
  markForTrain: boolean(),
19079
19115
  debug: boolean(),
19116
+ /** The note as it STANDS after the write — never absent here (a track with no
19117
+ * note reports `''`), for the same reason the booleans are required: a
19118
+ * surface that has just written must be able to render the note it now owns
19119
+ * without a re-fetch, and an absent key would read as "unchanged" on a
19120
+ * screen that has no previous value to keep. */
19121
+ debugNote: string(),
19080
19122
  favourited: boolean(),
19081
19123
  /** The lifecycle state the boolean was derived from. Required here (unlike on
19082
19124
  * a track row) because this shape is only ever produced by the write body,
@@ -13285,6 +13285,24 @@ var deviceProviderCapability = {
13285
13285
  })
13286
13286
  }
13287
13287
  };
13288
+ /**
13289
+ * Device Manager capability — hub-side singleton that unifies device persistence,
13290
+ * live registry access, and all management operations into a single tRPC surface.
13291
+ *
13292
+ * Replaces:
13293
+ * - `device-persistence` capability (persistence methods absorbed here)
13294
+ * - `device-management.router.ts` (deleted in Phase 2)
13295
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
13296
+ *
13297
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
13298
+ * fork into separate processes but never run on remote cluster agents. Therefore:
13299
+ * - No nodeId routing needed — this is a pure hub singleton.
13300
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
13301
+ * - No shadow registry or cross-node aggregation required.
13302
+ *
13303
+ * Forked workers register devices back to the hub via `ctx.devices`
13304
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
13305
+ */
13288
13306
  /** One child-placement directive on a container's `childLayout`. Structurally
13289
13307
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
13290
13308
  * shape for the same field. The child is identified by its re-sync-stable
@@ -19041,6 +19059,9 @@ var RetrainStatusSchema = _enum([
19041
19059
  *
19042
19060
  * `debug` does NOT pin; it is attention, not durability.
19043
19061
  *
19062
+ * `debugNote` is the operator's own words about WHAT should be checked on this
19063
+ * track, and it lives and dies with `debug` — see {@link MAX_TRACK_DEBUG_NOTE_LEN}.
19064
+ *
19044
19065
  * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
19045
19066
  * A favourited track is skipped by retention the same way `staging` is, but
19046
19067
  * it does not enter `none|staging|trained` and has no staging budget.
@@ -19051,6 +19072,21 @@ var TrackFlagFields = {
19051
19072
  markForTrain: boolean().optional(),
19052
19073
  /** Operator marked this track for diagnostic attention. */
19053
19074
  debug: boolean().optional(),
19075
+ /**
19076
+ * What the operator wants CHECKED on this track, in their own words.
19077
+ *
19078
+ * The flag alone says "look at this" and nothing about what for; a debug set
19079
+ * reviewed hours later is a list of tracks with no question attached. Bound
19080
+ * to `debug` on the WRITE side — the body clears it when `debug` goes off,
19081
+ * and refuses it on a track whose debug is off — so a note can never outlive
19082
+ * the attention it belongs to.
19083
+ *
19084
+ * `''` is a real value ("marked, nothing to add"); the ABSENT key means
19085
+ * "leave whatever is stored alone", which is what lets a surface turn debug
19086
+ * on without a note (a cancelled prompt) and what lets it edit the note
19087
+ * without touching the flag.
19088
+ */
19089
+ debugNote: string().max(500).optional(),
19054
19090
  /** Operator favourited this track. Pins it against pruning. */
19055
19091
  favourited: boolean().optional()
19056
19092
  };
@@ -19077,6 +19113,12 @@ var TrackFlagsSchema = object({
19077
19113
  trackId: string(),
19078
19114
  markForTrain: boolean(),
19079
19115
  debug: boolean(),
19116
+ /** The note as it STANDS after the write — never absent here (a track with no
19117
+ * note reports `''`), for the same reason the booleans are required: a
19118
+ * surface that has just written must be able to render the note it now owns
19119
+ * without a re-fetch, and an absent key would read as "unchanged" on a
19120
+ * screen that has no previous value to keep. */
19121
+ debugNote: string(),
19080
19122
  favourited: boolean(),
19081
19123
  /** The lifecycle state the boolean was derived from. Required here (unlike on
19082
19124
  * a track row) because this shape is only ever produced by the write body,
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-gv0Gmrm1.js");
6
+ const require_dist = require("../dist-BI0EGQ5s.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -5772,6 +5772,23 @@ function projectSynthetic(input, nowMs) {
5772
5772
  return values;
5773
5773
  }
5774
5774
  //#endregion
5775
+ //#region src/ha-export/node-reads.ts
5776
+ var UNREAD = {
5777
+ footageUsedBytes: null,
5778
+ footageFreePercent: null
5779
+ };
5780
+ function footageForNode(nodeId, usage) {
5781
+ if (usage === null || usage.nodeId !== nodeId) return UNREAD;
5782
+ const freePercents = (usage.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
5783
+ return {
5784
+ footageUsedBytes: usage.totalUsedBytes,
5785
+ footageFreePercent: freePercents.length > 0 ? Math.min(...freePercents) : null
5786
+ };
5787
+ }
5788
+ function shouldReadPackageStatus(node) {
5789
+ return !node.isHub && node.isOnline;
5790
+ }
5791
+ //#endregion
5775
5792
  //#region src/ha-export/rule-triggers.ts
5776
5793
  function reduceOne(row) {
5777
5794
  return {
@@ -6803,35 +6820,33 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6803
6820
  }
6804
6821
  }
6805
6822
  /**
6806
- * Per-node disk usage (Task D2) and per-AGENT package status (Task D3),
6807
- * fanned out from the topology this pass already read. Bounded per node —
6808
- * one unreachable node degrades its own two rows, never the others'.
6823
+ * Per-node disk usage (Task D2) and per-AGENT package status (Task D3).
6824
+ * `recording` is a singleton cap, so its usage is read ONCE, unpinned, and
6825
+ * attributed to the node the answer names (`footageForNode`); package
6826
+ * status is asked only of online agents (`shouldReadPackageStatus`). A node
6827
+ * that is not read stays `unknown` — unread, never failed.
6809
6828
  */
6810
6829
  async buildSyntheticNodes(topology) {
6830
+ const usage = await this.ctx.api.recording.getStorageUsage.query({}).catch((err) => {
6831
+ this.ctx.logger.warn("ha-export: could not read storage usage, the footage entities go unknown", { meta: { error: errMsg(err) } });
6832
+ return null;
6833
+ });
6811
6834
  let oldestFootageMs = null;
6835
+ for (const device of usage?.devices ?? []) {
6836
+ const oldest = device.oldestMs;
6837
+ if (oldest === null || oldest === void 0) continue;
6838
+ if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6839
+ }
6812
6840
  return {
6813
6841
  nodes: await Promise.all(topology.map(async (node) => {
6814
- const disk = await this.ctx.api.recording.getStorageUsage.query({}, require_dist.nodePin(node.id)).catch((err) => {
6815
- this.ctx.logger.warn("ha-export: could not read storage usage for a node, its disk entities go unknown", { meta: {
6816
- nodeId: node.id,
6817
- error: errMsg(err)
6818
- } });
6819
- return null;
6820
- });
6821
- const freePercents = (disk?.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
6822
- const footageFreePercent = freePercents.length > 0 ? Math.min(...freePercents) : null;
6823
- for (const device of disk?.devices ?? []) {
6824
- const oldest = device.oldestMs;
6825
- if (oldest === null || oldest === void 0) continue;
6826
- if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6827
- }
6828
- const packageStatus = node.isHub ? null : await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6842
+ const footage = footageForNode(node.id, usage);
6843
+ const packageStatus = shouldReadPackageStatus(node) ? await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6829
6844
  this.ctx.logger.warn("ha-export: could not read package status for an agent node, its update entities go unknown", { meta: {
6830
6845
  nodeId: node.id,
6831
6846
  error: errMsg(err)
6832
6847
  } });
6833
6848
  return null;
6834
- });
6849
+ }) : null;
6835
6850
  return {
6836
6851
  id: node.id,
6837
6852
  name: node.name,
@@ -6840,8 +6855,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6840
6855
  memoryPercent: node.memoryPercent,
6841
6856
  uptime: node.uptime,
6842
6857
  isHub: node.isHub,
6843
- footageUsedBytes: disk?.totalUsedBytes ?? null,
6844
- footageFreePercent,
6858
+ footageUsedBytes: footage.footageUsedBytes,
6859
+ footageFreePercent: footage.footageFreePercent,
6845
6860
  rootPackageVersion: node.rootPackage?.version ?? null,
6846
6861
  updateAvailable: packageStatus?.updateAvailable ?? null
6847
6862
  };
@@ -1,4 +1,4 @@
1
- import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-CQzCvoCx.mjs";
1
+ import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-CXuG5e6V.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -5767,6 +5767,23 @@ function projectSynthetic(input, nowMs) {
5767
5767
  return values;
5768
5768
  }
5769
5769
  //#endregion
5770
+ //#region src/ha-export/node-reads.ts
5771
+ var UNREAD = {
5772
+ footageUsedBytes: null,
5773
+ footageFreePercent: null
5774
+ };
5775
+ function footageForNode(nodeId, usage) {
5776
+ if (usage === null || usage.nodeId !== nodeId) return UNREAD;
5777
+ const freePercents = (usage.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
5778
+ return {
5779
+ footageUsedBytes: usage.totalUsedBytes,
5780
+ footageFreePercent: freePercents.length > 0 ? Math.min(...freePercents) : null
5781
+ };
5782
+ }
5783
+ function shouldReadPackageStatus(node) {
5784
+ return !node.isHub && node.isOnline;
5785
+ }
5786
+ //#endregion
5770
5787
  //#region src/ha-export/rule-triggers.ts
5771
5788
  function reduceOne(row) {
5772
5789
  return {
@@ -6798,35 +6815,33 @@ var HaExportAddon = class extends BaseAddon {
6798
6815
  }
6799
6816
  }
6800
6817
  /**
6801
- * Per-node disk usage (Task D2) and per-AGENT package status (Task D3),
6802
- * fanned out from the topology this pass already read. Bounded per node —
6803
- * one unreachable node degrades its own two rows, never the others'.
6818
+ * Per-node disk usage (Task D2) and per-AGENT package status (Task D3).
6819
+ * `recording` is a singleton cap, so its usage is read ONCE, unpinned, and
6820
+ * attributed to the node the answer names (`footageForNode`); package
6821
+ * status is asked only of online agents (`shouldReadPackageStatus`). A node
6822
+ * that is not read stays `unknown` — unread, never failed.
6804
6823
  */
6805
6824
  async buildSyntheticNodes(topology) {
6825
+ const usage = await this.ctx.api.recording.getStorageUsage.query({}).catch((err) => {
6826
+ this.ctx.logger.warn("ha-export: could not read storage usage, the footage entities go unknown", { meta: { error: errMsg(err) } });
6827
+ return null;
6828
+ });
6806
6829
  let oldestFootageMs = null;
6830
+ for (const device of usage?.devices ?? []) {
6831
+ const oldest = device.oldestMs;
6832
+ if (oldest === null || oldest === void 0) continue;
6833
+ if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6834
+ }
6807
6835
  return {
6808
6836
  nodes: await Promise.all(topology.map(async (node) => {
6809
- const disk = await this.ctx.api.recording.getStorageUsage.query({}, nodePin(node.id)).catch((err) => {
6810
- this.ctx.logger.warn("ha-export: could not read storage usage for a node, its disk entities go unknown", { meta: {
6811
- nodeId: node.id,
6812
- error: errMsg(err)
6813
- } });
6814
- return null;
6815
- });
6816
- const freePercents = (disk?.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
6817
- const footageFreePercent = freePercents.length > 0 ? Math.min(...freePercents) : null;
6818
- for (const device of disk?.devices ?? []) {
6819
- const oldest = device.oldestMs;
6820
- if (oldest === null || oldest === void 0) continue;
6821
- if (oldestFootageMs === null || oldest < oldestFootageMs) oldestFootageMs = oldest;
6822
- }
6823
- const packageStatus = node.isHub ? null : await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6837
+ const footage = footageForNode(node.id, usage);
6838
+ const packageStatus = shouldReadPackageStatus(node) ? await this.ctx.api.serverManagement.getServerPackageStatus.query({ nodeId: node.id }).catch((err) => {
6824
6839
  this.ctx.logger.warn("ha-export: could not read package status for an agent node, its update entities go unknown", { meta: {
6825
6840
  nodeId: node.id,
6826
6841
  error: errMsg(err)
6827
6842
  } });
6828
6843
  return null;
6829
- });
6844
+ }) : null;
6830
6845
  return {
6831
6846
  id: node.id,
6832
6847
  name: node.name,
@@ -6835,8 +6850,8 @@ var HaExportAddon = class extends BaseAddon {
6835
6850
  memoryPercent: node.memoryPercent,
6836
6851
  uptime: node.uptime,
6837
6852
  isHub: node.isHub,
6838
- footageUsedBytes: disk?.totalUsedBytes ?? null,
6839
- footageFreePercent,
6853
+ footageUsedBytes: footage.footageUsedBytes,
6854
+ footageFreePercent: footage.footageFreePercent,
6840
6855
  rootPackageVersion: node.rootPackage?.version ?? null,
6841
6856
  updateAvailable: packageStatus?.updateAvailable ?? null
6842
6857
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.78",
3
+ "version": "1.2.79",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",