@camstack/addon-provider-homematic 1.2.52 → 1.2.54

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
@@ -7727,7 +7727,8 @@ var OpsLogReasonSchema = _enum([
7727
7727
  "quota",
7728
7728
  "manual",
7729
7729
  "operator",
7730
- "maintenance"
7730
+ "maintenance",
7731
+ "orphaned-device"
7731
7732
  ]);
7732
7733
  /** One audit row, shared verbatim by both domains. */
7733
7734
  var OpsLogEntrySchema = object({
@@ -13088,10 +13089,39 @@ var DevicePersistConfigPayloadSchema = object({
13088
13089
  deviceId: number(),
13089
13090
  data: record(string(), unknown())
13090
13091
  });
13092
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13093
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13094
+ var MigrateSwitchOutcomeSchema = _enum([
13095
+ "off",
13096
+ "on",
13097
+ "not-offered",
13098
+ "unreachable"
13099
+ ]);
13100
+ var MigrateSwitchReportSchema = object({
13101
+ deviceId: number(),
13102
+ switchId: CameraSwitchIdSchema,
13103
+ outcome: MigrateSwitchOutcomeSchema,
13104
+ detail: string().optional()
13105
+ });
13106
+ var MigrateDeviceResultSchema = object({
13107
+ sourceId: number(),
13108
+ targetId: number(),
13109
+ switches: array(MigrateSwitchReportSchema).readonly(),
13110
+ /** Switches that could NOT be turned off on the source. Empty is the only
13111
+ * value meaning the replaced hardware is quiet. */
13112
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13113
+ swapped: boolean()
13114
+ });
13091
13115
  method(object({
13092
13116
  addonId: string(),
13093
13117
  stableId: string()
13094
- }), object({ id: number() }), { kind: "mutation" }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13118
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13119
+ sourceId: number(),
13120
+ targetId: number()
13121
+ }), MigrateDeviceResultSchema, {
13122
+ kind: "mutation",
13123
+ auth: "admin"
13124
+ }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13095
13125
  deviceId: number(),
13096
13126
  name: string()
13097
13127
  }), _void(), {
@@ -19266,6 +19296,34 @@ var KeyEventsForDeviceSchema = object({
19266
19296
  deviceId: number(),
19267
19297
  events: array(KeyEventSchema).readonly()
19268
19298
  });
19299
+ /** One non-empty bucket of the timeline histogram. */
19300
+ var EventDensityBucketSchema = object({
19301
+ bucketStart: number(),
19302
+ motion: number().int(),
19303
+ object: number().int(),
19304
+ audio: number().int()
19305
+ });
19306
+ /**
19307
+ * One camera's row in a `getEventDensityBatch` answer.
19308
+ *
19309
+ * TWO facts, and the timeline draws them differently:
19310
+ *
19311
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19312
+ * claim: read, and nothing happened in the window.
19313
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19314
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19315
+ * not quiet.
19316
+ *
19317
+ * Fanned out per camera the difference was free — one query rejected while the
19318
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19319
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19320
+ * got eleven cannot tell which one is missing, or that any is.
19321
+ */
19322
+ var EventDensityForDeviceSchema = object({
19323
+ deviceId: number(),
19324
+ read: _enum(["read", "unreadable"]),
19325
+ buckets: array(EventDensityBucketSchema).readonly()
19326
+ });
19269
19327
  object({
19270
19328
  trackId: string(),
19271
19329
  className: string(),
@@ -19576,12 +19634,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19576
19634
  since: number(),
19577
19635
  until: number(),
19578
19636
  bucketMs: number().int().positive()
19579
- }), array(object({
19580
- bucketStart: number(),
19581
- motion: number().int(),
19582
- object: number().int(),
19583
- audio: number().int()
19584
- })).readonly()), method(object({
19637
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19638
+ deviceIds: array(number()).min(1).max(200),
19639
+ since: number(),
19640
+ until: number(),
19641
+ bucketMs: number().int().positive()
19642
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19585
19643
  deviceId: number(),
19586
19644
  cutoffMs: number()
19587
19645
  }), object({
@@ -28606,6 +28664,39 @@ var RecordingDaysSchema = object({
28606
28664
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28607
28665
  days: array(number())
28608
28666
  });
28667
+ /**
28668
+ * One camera's row in a `getAvailabilityBatch` answer.
28669
+ *
28670
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28671
+ * batch collapses the transport, not the work — plus the one thing the singular
28672
+ * method never had to say:
28673
+ *
28674
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28675
+ * no footage in the window", which is a real claim.
28676
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28677
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28678
+ * that emptiness means NOTHING.
28679
+ *
28680
+ * A timeline that renders the second as the first tells an operator there is no
28681
+ * footage when the truth is that nobody looked.
28682
+ */
28683
+ var RecordingAvailabilityForDeviceSchema = object({
28684
+ deviceId: number(),
28685
+ read: _enum(["read", "unreadable"]),
28686
+ ranges: array(RecordingRangeSchema).readonly()
28687
+ });
28688
+ /**
28689
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28690
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28691
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28692
+ * the date-picker's day dots must not spell them the same.
28693
+ */
28694
+ var RecordingDaysForDeviceSchema = object({
28695
+ deviceId: number(),
28696
+ read: _enum(["read", "unreadable"]),
28697
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28698
+ days: array(number()).readonly()
28699
+ });
28609
28700
  var RecordingManifestSchema = object({
28610
28701
  deviceId: number(),
28611
28702
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28840,6 +28931,13 @@ method(object({
28840
28931
  }), RecordingAvailabilitySchema, {
28841
28932
  kind: "query",
28842
28933
  auth: "protected"
28934
+ }), method(object({
28935
+ deviceIds: array(number()).min(1).max(200),
28936
+ fromMs: number(),
28937
+ toMs: number()
28938
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28939
+ kind: "query",
28940
+ auth: "protected"
28843
28941
  }), method(object({
28844
28942
  deviceId: number(),
28845
28943
  fromMs: number(),
@@ -28848,6 +28946,14 @@ method(object({
28848
28946
  }), RecordingDaysSchema, {
28849
28947
  kind: "query",
28850
28948
  auth: "protected"
28949
+ }), method(object({
28950
+ deviceIds: array(number()).min(1).max(200),
28951
+ fromMs: number(),
28952
+ toMs: number(),
28953
+ tzOffsetMinutes: number()
28954
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28955
+ kind: "query",
28956
+ auth: "protected"
28851
28957
  }), method(object({
28852
28958
  deviceId: number(),
28853
28959
  fromMs: number(),
@@ -34010,6 +34116,12 @@ Object.freeze({
34010
34116
  addonId: null,
34011
34117
  access: "view"
34012
34118
  },
34119
+ "deviceManager.migrateDevice": {
34120
+ capName: "device-manager",
34121
+ capScope: "system",
34122
+ addonId: null,
34123
+ access: "create"
34124
+ },
34013
34125
  "deviceManager.persistConfig": {
34014
34126
  capName: "device-manager",
34015
34127
  capScope: "system",
@@ -35744,6 +35856,12 @@ Object.freeze({
35744
35856
  addonId: null,
35745
35857
  access: "view"
35746
35858
  },
35859
+ "pipelineAnalytics.getEventDensityBatch": {
35860
+ capName: "pipeline-analytics",
35861
+ capScope: "device",
35862
+ addonId: null,
35863
+ access: "view"
35864
+ },
35747
35865
  "pipelineAnalytics.getEventMedia": {
35748
35866
  capName: "pipeline-analytics",
35749
35867
  capScope: "device",
@@ -36866,12 +36984,24 @@ Object.freeze({
36866
36984
  addonId: null,
36867
36985
  access: "view"
36868
36986
  },
36987
+ "recording.getAvailabilityBatch": {
36988
+ capName: "recording",
36989
+ capScope: "system",
36990
+ addonId: null,
36991
+ access: "view"
36992
+ },
36869
36993
  "recording.getDaysWithRecordings": {
36870
36994
  capName: "recording",
36871
36995
  capScope: "system",
36872
36996
  addonId: null,
36873
36997
  access: "view"
36874
36998
  },
36999
+ "recording.getDaysWithRecordingsBatch": {
37000
+ capName: "recording",
37001
+ capScope: "system",
37002
+ addonId: null,
37003
+ access: "view"
37004
+ },
36875
37005
  "recording.getDeviceConfig": {
36876
37006
  capName: "recording",
36877
37007
  capScope: "system",
@@ -39476,6 +39606,11 @@ Object.freeze({
39476
39606
  form: "single",
39477
39607
  optional: false
39478
39608
  }],
39609
+ "pipelineAnalytics.getEventDensityBatch": [{
39610
+ name: "deviceIds",
39611
+ form: "array",
39612
+ optional: false
39613
+ }],
39479
39614
  "pipelineAnalytics.getEventMedia": [{
39480
39615
  name: "deviceId",
39481
39616
  form: "single",
@@ -39891,11 +40026,21 @@ Object.freeze({
39891
40026
  form: "single",
39892
40027
  optional: false
39893
40028
  }],
40029
+ "recording.getAvailabilityBatch": [{
40030
+ name: "deviceIds",
40031
+ form: "array",
40032
+ optional: false
40033
+ }],
39894
40034
  "recording.getDaysWithRecordings": [{
39895
40035
  name: "deviceId",
39896
40036
  form: "single",
39897
40037
  optional: false
39898
40038
  }],
40039
+ "recording.getDaysWithRecordingsBatch": [{
40040
+ name: "deviceIds",
40041
+ form: "array",
40042
+ optional: false
40043
+ }],
39899
40044
  "recording.getDeviceConfig": [{
39900
40045
  name: "deviceId",
39901
40046
  form: "single",
package/dist/addon.mjs CHANGED
@@ -7728,7 +7728,8 @@ var OpsLogReasonSchema = _enum([
7728
7728
  "quota",
7729
7729
  "manual",
7730
7730
  "operator",
7731
- "maintenance"
7731
+ "maintenance",
7732
+ "orphaned-device"
7732
7733
  ]);
7733
7734
  /** One audit row, shared verbatim by both domains. */
7734
7735
  var OpsLogEntrySchema = object({
@@ -13089,10 +13090,39 @@ var DevicePersistConfigPayloadSchema = object({
13089
13090
  deviceId: number(),
13090
13091
  data: record(string(), unknown())
13091
13092
  });
13093
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13094
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13095
+ var MigrateSwitchOutcomeSchema = _enum([
13096
+ "off",
13097
+ "on",
13098
+ "not-offered",
13099
+ "unreachable"
13100
+ ]);
13101
+ var MigrateSwitchReportSchema = object({
13102
+ deviceId: number(),
13103
+ switchId: CameraSwitchIdSchema,
13104
+ outcome: MigrateSwitchOutcomeSchema,
13105
+ detail: string().optional()
13106
+ });
13107
+ var MigrateDeviceResultSchema = object({
13108
+ sourceId: number(),
13109
+ targetId: number(),
13110
+ switches: array(MigrateSwitchReportSchema).readonly(),
13111
+ /** Switches that could NOT be turned off on the source. Empty is the only
13112
+ * value meaning the replaced hardware is quiet. */
13113
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13114
+ swapped: boolean()
13115
+ });
13092
13116
  method(object({
13093
13117
  addonId: string(),
13094
13118
  stableId: string()
13095
- }), object({ id: number() }), { kind: "mutation" }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13119
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13120
+ sourceId: number(),
13121
+ targetId: number()
13122
+ }), MigrateDeviceResultSchema, {
13123
+ kind: "mutation",
13124
+ auth: "admin"
13125
+ }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13096
13126
  deviceId: number(),
13097
13127
  name: string()
13098
13128
  }), _void(), {
@@ -19267,6 +19297,34 @@ var KeyEventsForDeviceSchema = object({
19267
19297
  deviceId: number(),
19268
19298
  events: array(KeyEventSchema).readonly()
19269
19299
  });
19300
+ /** One non-empty bucket of the timeline histogram. */
19301
+ var EventDensityBucketSchema = object({
19302
+ bucketStart: number(),
19303
+ motion: number().int(),
19304
+ object: number().int(),
19305
+ audio: number().int()
19306
+ });
19307
+ /**
19308
+ * One camera's row in a `getEventDensityBatch` answer.
19309
+ *
19310
+ * TWO facts, and the timeline draws them differently:
19311
+ *
19312
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19313
+ * claim: read, and nothing happened in the window.
19314
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19315
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19316
+ * not quiet.
19317
+ *
19318
+ * Fanned out per camera the difference was free — one query rejected while the
19319
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19320
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19321
+ * got eleven cannot tell which one is missing, or that any is.
19322
+ */
19323
+ var EventDensityForDeviceSchema = object({
19324
+ deviceId: number(),
19325
+ read: _enum(["read", "unreadable"]),
19326
+ buckets: array(EventDensityBucketSchema).readonly()
19327
+ });
19270
19328
  object({
19271
19329
  trackId: string(),
19272
19330
  className: string(),
@@ -19577,12 +19635,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19577
19635
  since: number(),
19578
19636
  until: number(),
19579
19637
  bucketMs: number().int().positive()
19580
- }), array(object({
19581
- bucketStart: number(),
19582
- motion: number().int(),
19583
- object: number().int(),
19584
- audio: number().int()
19585
- })).readonly()), method(object({
19638
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19639
+ deviceIds: array(number()).min(1).max(200),
19640
+ since: number(),
19641
+ until: number(),
19642
+ bucketMs: number().int().positive()
19643
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19586
19644
  deviceId: number(),
19587
19645
  cutoffMs: number()
19588
19646
  }), object({
@@ -28607,6 +28665,39 @@ var RecordingDaysSchema = object({
28607
28665
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28608
28666
  days: array(number())
28609
28667
  });
28668
+ /**
28669
+ * One camera's row in a `getAvailabilityBatch` answer.
28670
+ *
28671
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28672
+ * batch collapses the transport, not the work — plus the one thing the singular
28673
+ * method never had to say:
28674
+ *
28675
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28676
+ * no footage in the window", which is a real claim.
28677
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28678
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28679
+ * that emptiness means NOTHING.
28680
+ *
28681
+ * A timeline that renders the second as the first tells an operator there is no
28682
+ * footage when the truth is that nobody looked.
28683
+ */
28684
+ var RecordingAvailabilityForDeviceSchema = object({
28685
+ deviceId: number(),
28686
+ read: _enum(["read", "unreadable"]),
28687
+ ranges: array(RecordingRangeSchema).readonly()
28688
+ });
28689
+ /**
28690
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28691
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28692
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28693
+ * the date-picker's day dots must not spell them the same.
28694
+ */
28695
+ var RecordingDaysForDeviceSchema = object({
28696
+ deviceId: number(),
28697
+ read: _enum(["read", "unreadable"]),
28698
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28699
+ days: array(number()).readonly()
28700
+ });
28610
28701
  var RecordingManifestSchema = object({
28611
28702
  deviceId: number(),
28612
28703
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28841,6 +28932,13 @@ method(object({
28841
28932
  }), RecordingAvailabilitySchema, {
28842
28933
  kind: "query",
28843
28934
  auth: "protected"
28935
+ }), method(object({
28936
+ deviceIds: array(number()).min(1).max(200),
28937
+ fromMs: number(),
28938
+ toMs: number()
28939
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28940
+ kind: "query",
28941
+ auth: "protected"
28844
28942
  }), method(object({
28845
28943
  deviceId: number(),
28846
28944
  fromMs: number(),
@@ -28849,6 +28947,14 @@ method(object({
28849
28947
  }), RecordingDaysSchema, {
28850
28948
  kind: "query",
28851
28949
  auth: "protected"
28950
+ }), method(object({
28951
+ deviceIds: array(number()).min(1).max(200),
28952
+ fromMs: number(),
28953
+ toMs: number(),
28954
+ tzOffsetMinutes: number()
28955
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28956
+ kind: "query",
28957
+ auth: "protected"
28852
28958
  }), method(object({
28853
28959
  deviceId: number(),
28854
28960
  fromMs: number(),
@@ -34011,6 +34117,12 @@ Object.freeze({
34011
34117
  addonId: null,
34012
34118
  access: "view"
34013
34119
  },
34120
+ "deviceManager.migrateDevice": {
34121
+ capName: "device-manager",
34122
+ capScope: "system",
34123
+ addonId: null,
34124
+ access: "create"
34125
+ },
34014
34126
  "deviceManager.persistConfig": {
34015
34127
  capName: "device-manager",
34016
34128
  capScope: "system",
@@ -35745,6 +35857,12 @@ Object.freeze({
35745
35857
  addonId: null,
35746
35858
  access: "view"
35747
35859
  },
35860
+ "pipelineAnalytics.getEventDensityBatch": {
35861
+ capName: "pipeline-analytics",
35862
+ capScope: "device",
35863
+ addonId: null,
35864
+ access: "view"
35865
+ },
35748
35866
  "pipelineAnalytics.getEventMedia": {
35749
35867
  capName: "pipeline-analytics",
35750
35868
  capScope: "device",
@@ -36867,12 +36985,24 @@ Object.freeze({
36867
36985
  addonId: null,
36868
36986
  access: "view"
36869
36987
  },
36988
+ "recording.getAvailabilityBatch": {
36989
+ capName: "recording",
36990
+ capScope: "system",
36991
+ addonId: null,
36992
+ access: "view"
36993
+ },
36870
36994
  "recording.getDaysWithRecordings": {
36871
36995
  capName: "recording",
36872
36996
  capScope: "system",
36873
36997
  addonId: null,
36874
36998
  access: "view"
36875
36999
  },
37000
+ "recording.getDaysWithRecordingsBatch": {
37001
+ capName: "recording",
37002
+ capScope: "system",
37003
+ addonId: null,
37004
+ access: "view"
37005
+ },
36876
37006
  "recording.getDeviceConfig": {
36877
37007
  capName: "recording",
36878
37008
  capScope: "system",
@@ -39477,6 +39607,11 @@ Object.freeze({
39477
39607
  form: "single",
39478
39608
  optional: false
39479
39609
  }],
39610
+ "pipelineAnalytics.getEventDensityBatch": [{
39611
+ name: "deviceIds",
39612
+ form: "array",
39613
+ optional: false
39614
+ }],
39480
39615
  "pipelineAnalytics.getEventMedia": [{
39481
39616
  name: "deviceId",
39482
39617
  form: "single",
@@ -39892,11 +40027,21 @@ Object.freeze({
39892
40027
  form: "single",
39893
40028
  optional: false
39894
40029
  }],
40030
+ "recording.getAvailabilityBatch": [{
40031
+ name: "deviceIds",
40032
+ form: "array",
40033
+ optional: false
40034
+ }],
39895
40035
  "recording.getDaysWithRecordings": [{
39896
40036
  name: "deviceId",
39897
40037
  form: "single",
39898
40038
  optional: false
39899
40039
  }],
40040
+ "recording.getDaysWithRecordingsBatch": [{
40041
+ name: "deviceIds",
40042
+ form: "array",
40043
+ optional: false
40044
+ }],
39900
40045
  "recording.getDeviceConfig": [{
39901
40046
  name: "deviceId",
39902
40047
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homematic",
3
- "version": "1.2.52",
3
+ "version": "1.2.54",
4
4
  "description": "Homematic / HomematicIP (CCU3 / RaspberryMatic) device-provider addon for CamStack — wraps the nodehomematic library",
5
5
  "keywords": [
6
6
  "camstack",