@camstack/addon-export-hap 1.2.62 → 1.2.64

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.
@@ -8384,7 +8384,8 @@ var OpsLogReasonSchema = _enum([
8384
8384
  "quota",
8385
8385
  "manual",
8386
8386
  "operator",
8387
- "maintenance"
8387
+ "maintenance",
8388
+ "orphaned-device"
8388
8389
  ]);
8389
8390
  /** One audit row, shared verbatim by both domains. */
8390
8391
  var OpsLogEntrySchema = object({
@@ -13550,10 +13551,39 @@ var DevicePersistConfigPayloadSchema = object({
13550
13551
  deviceId: number(),
13551
13552
  data: record(string(), unknown())
13552
13553
  });
13554
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13555
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13556
+ var MigrateSwitchOutcomeSchema = _enum([
13557
+ "off",
13558
+ "on",
13559
+ "not-offered",
13560
+ "unreachable"
13561
+ ]);
13562
+ var MigrateSwitchReportSchema = object({
13563
+ deviceId: number(),
13564
+ switchId: CameraSwitchIdSchema,
13565
+ outcome: MigrateSwitchOutcomeSchema,
13566
+ detail: string().optional()
13567
+ });
13568
+ var MigrateDeviceResultSchema = object({
13569
+ sourceId: number(),
13570
+ targetId: number(),
13571
+ switches: array(MigrateSwitchReportSchema).readonly(),
13572
+ /** Switches that could NOT be turned off on the source. Empty is the only
13573
+ * value meaning the replaced hardware is quiet. */
13574
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13575
+ swapped: boolean()
13576
+ });
13553
13577
  method(object({
13554
13578
  addonId: string(),
13555
13579
  stableId: string()
13556
- }), 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({
13580
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13581
+ sourceId: number(),
13582
+ targetId: number()
13583
+ }), MigrateDeviceResultSchema, {
13584
+ kind: "mutation",
13585
+ auth: "admin"
13586
+ }), 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({
13557
13587
  deviceId: number(),
13558
13588
  name: string()
13559
13589
  }), _void(), {
@@ -19650,6 +19680,34 @@ var KeyEventsForDeviceSchema = object({
19650
19680
  deviceId: number(),
19651
19681
  events: array(KeyEventSchema).readonly()
19652
19682
  });
19683
+ /** One non-empty bucket of the timeline histogram. */
19684
+ var EventDensityBucketSchema = object({
19685
+ bucketStart: number(),
19686
+ motion: number().int(),
19687
+ object: number().int(),
19688
+ audio: number().int()
19689
+ });
19690
+ /**
19691
+ * One camera's row in a `getEventDensityBatch` answer.
19692
+ *
19693
+ * TWO facts, and the timeline draws them differently:
19694
+ *
19695
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19696
+ * claim: read, and nothing happened in the window.
19697
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19698
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19699
+ * not quiet.
19700
+ *
19701
+ * Fanned out per camera the difference was free — one query rejected while the
19702
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19703
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19704
+ * got eleven cannot tell which one is missing, or that any is.
19705
+ */
19706
+ var EventDensityForDeviceSchema = object({
19707
+ deviceId: number(),
19708
+ read: _enum(["read", "unreadable"]),
19709
+ buckets: array(EventDensityBucketSchema).readonly()
19710
+ });
19653
19711
  object({
19654
19712
  trackId: string(),
19655
19713
  className: string(),
@@ -19960,12 +20018,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19960
20018
  since: number(),
19961
20019
  until: number(),
19962
20020
  bucketMs: number().int().positive()
19963
- }), array(object({
19964
- bucketStart: number(),
19965
- motion: number().int(),
19966
- object: number().int(),
19967
- audio: number().int()
19968
- })).readonly()), method(object({
20021
+ }), array(EventDensityBucketSchema).readonly()), method(object({
20022
+ deviceIds: array(number()).min(1).max(200),
20023
+ since: number(),
20024
+ until: number(),
20025
+ bucketMs: number().int().positive()
20026
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19969
20027
  deviceId: number(),
19970
20028
  cutoffMs: number()
19971
20029
  }), object({
@@ -27323,6 +27381,39 @@ var RecordingDaysSchema = object({
27323
27381
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
27324
27382
  days: array(number())
27325
27383
  });
27384
+ /**
27385
+ * One camera's row in a `getAvailabilityBatch` answer.
27386
+ *
27387
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
27388
+ * batch collapses the transport, not the work — plus the one thing the singular
27389
+ * method never had to say:
27390
+ *
27391
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
27392
+ * no footage in the window", which is a real claim.
27393
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
27394
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
27395
+ * that emptiness means NOTHING.
27396
+ *
27397
+ * A timeline that renders the second as the first tells an operator there is no
27398
+ * footage when the truth is that nobody looked.
27399
+ */
27400
+ var RecordingAvailabilityForDeviceSchema = object({
27401
+ deviceId: number(),
27402
+ read: _enum(["read", "unreadable"]),
27403
+ ranges: array(RecordingRangeSchema).readonly()
27404
+ });
27405
+ /**
27406
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
27407
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
27408
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
27409
+ * the date-picker's day dots must not spell them the same.
27410
+ */
27411
+ var RecordingDaysForDeviceSchema = object({
27412
+ deviceId: number(),
27413
+ read: _enum(["read", "unreadable"]),
27414
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
27415
+ days: array(number()).readonly()
27416
+ });
27326
27417
  var RecordingManifestSchema = object({
27327
27418
  deviceId: number(),
27328
27419
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -27557,6 +27648,13 @@ method(object({
27557
27648
  }), RecordingAvailabilitySchema, {
27558
27649
  kind: "query",
27559
27650
  auth: "protected"
27651
+ }), method(object({
27652
+ deviceIds: array(number()).min(1).max(200),
27653
+ fromMs: number(),
27654
+ toMs: number()
27655
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
27656
+ kind: "query",
27657
+ auth: "protected"
27560
27658
  }), method(object({
27561
27659
  deviceId: number(),
27562
27660
  fromMs: number(),
@@ -27565,6 +27663,14 @@ method(object({
27565
27663
  }), RecordingDaysSchema, {
27566
27664
  kind: "query",
27567
27665
  auth: "protected"
27666
+ }), method(object({
27667
+ deviceIds: array(number()).min(1).max(200),
27668
+ fromMs: number(),
27669
+ toMs: number(),
27670
+ tzOffsetMinutes: number()
27671
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
27672
+ kind: "query",
27673
+ auth: "protected"
27568
27674
  }), method(object({
27569
27675
  deviceId: number(),
27570
27676
  fromMs: number(),
@@ -31053,6 +31159,12 @@ Object.freeze({
31053
31159
  addonId: null,
31054
31160
  access: "view"
31055
31161
  },
31162
+ "deviceManager.migrateDevice": {
31163
+ capName: "device-manager",
31164
+ capScope: "system",
31165
+ addonId: null,
31166
+ access: "create"
31167
+ },
31056
31168
  "deviceManager.persistConfig": {
31057
31169
  capName: "device-manager",
31058
31170
  capScope: "system",
@@ -32787,6 +32899,12 @@ Object.freeze({
32787
32899
  addonId: null,
32788
32900
  access: "view"
32789
32901
  },
32902
+ "pipelineAnalytics.getEventDensityBatch": {
32903
+ capName: "pipeline-analytics",
32904
+ capScope: "device",
32905
+ addonId: null,
32906
+ access: "view"
32907
+ },
32790
32908
  "pipelineAnalytics.getEventMedia": {
32791
32909
  capName: "pipeline-analytics",
32792
32910
  capScope: "device",
@@ -33909,12 +34027,24 @@ Object.freeze({
33909
34027
  addonId: null,
33910
34028
  access: "view"
33911
34029
  },
34030
+ "recording.getAvailabilityBatch": {
34031
+ capName: "recording",
34032
+ capScope: "system",
34033
+ addonId: null,
34034
+ access: "view"
34035
+ },
33912
34036
  "recording.getDaysWithRecordings": {
33913
34037
  capName: "recording",
33914
34038
  capScope: "system",
33915
34039
  addonId: null,
33916
34040
  access: "view"
33917
34041
  },
34042
+ "recording.getDaysWithRecordingsBatch": {
34043
+ capName: "recording",
34044
+ capScope: "system",
34045
+ addonId: null,
34046
+ access: "view"
34047
+ },
33918
34048
  "recording.getDeviceConfig": {
33919
34049
  capName: "recording",
33920
34050
  capScope: "system",
@@ -36519,6 +36649,11 @@ Object.freeze({
36519
36649
  form: "single",
36520
36650
  optional: false
36521
36651
  }],
36652
+ "pipelineAnalytics.getEventDensityBatch": [{
36653
+ name: "deviceIds",
36654
+ form: "array",
36655
+ optional: false
36656
+ }],
36522
36657
  "pipelineAnalytics.getEventMedia": [{
36523
36658
  name: "deviceId",
36524
36659
  form: "single",
@@ -36934,11 +37069,21 @@ Object.freeze({
36934
37069
  form: "single",
36935
37070
  optional: false
36936
37071
  }],
37072
+ "recording.getAvailabilityBatch": [{
37073
+ name: "deviceIds",
37074
+ form: "array",
37075
+ optional: false
37076
+ }],
36937
37077
  "recording.getDaysWithRecordings": [{
36938
37078
  name: "deviceId",
36939
37079
  form: "single",
36940
37080
  optional: false
36941
37081
  }],
37082
+ "recording.getDaysWithRecordingsBatch": [{
37083
+ name: "deviceIds",
37084
+ form: "array",
37085
+ optional: false
37086
+ }],
36942
37087
  "recording.getDeviceConfig": [{
36943
37088
  name: "deviceId",
36944
37089
  form: "single",
@@ -8372,7 +8372,8 @@ var OpsLogReasonSchema = _enum([
8372
8372
  "quota",
8373
8373
  "manual",
8374
8374
  "operator",
8375
- "maintenance"
8375
+ "maintenance",
8376
+ "orphaned-device"
8376
8377
  ]);
8377
8378
  /** One audit row, shared verbatim by both domains. */
8378
8379
  var OpsLogEntrySchema = object({
@@ -13538,10 +13539,39 @@ var DevicePersistConfigPayloadSchema = object({
13538
13539
  deviceId: number(),
13539
13540
  data: record(string(), unknown())
13540
13541
  });
13542
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13543
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13544
+ var MigrateSwitchOutcomeSchema = _enum([
13545
+ "off",
13546
+ "on",
13547
+ "not-offered",
13548
+ "unreachable"
13549
+ ]);
13550
+ var MigrateSwitchReportSchema = object({
13551
+ deviceId: number(),
13552
+ switchId: CameraSwitchIdSchema,
13553
+ outcome: MigrateSwitchOutcomeSchema,
13554
+ detail: string().optional()
13555
+ });
13556
+ var MigrateDeviceResultSchema = object({
13557
+ sourceId: number(),
13558
+ targetId: number(),
13559
+ switches: array(MigrateSwitchReportSchema).readonly(),
13560
+ /** Switches that could NOT be turned off on the source. Empty is the only
13561
+ * value meaning the replaced hardware is quiet. */
13562
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13563
+ swapped: boolean()
13564
+ });
13541
13565
  method(object({
13542
13566
  addonId: string(),
13543
13567
  stableId: string()
13544
- }), 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({
13568
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13569
+ sourceId: number(),
13570
+ targetId: number()
13571
+ }), MigrateDeviceResultSchema, {
13572
+ kind: "mutation",
13573
+ auth: "admin"
13574
+ }), 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({
13545
13575
  deviceId: number(),
13546
13576
  name: string()
13547
13577
  }), _void(), {
@@ -19638,6 +19668,34 @@ var KeyEventsForDeviceSchema = object({
19638
19668
  deviceId: number(),
19639
19669
  events: array(KeyEventSchema).readonly()
19640
19670
  });
19671
+ /** One non-empty bucket of the timeline histogram. */
19672
+ var EventDensityBucketSchema = object({
19673
+ bucketStart: number(),
19674
+ motion: number().int(),
19675
+ object: number().int(),
19676
+ audio: number().int()
19677
+ });
19678
+ /**
19679
+ * One camera's row in a `getEventDensityBatch` answer.
19680
+ *
19681
+ * TWO facts, and the timeline draws them differently:
19682
+ *
19683
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19684
+ * claim: read, and nothing happened in the window.
19685
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19686
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19687
+ * not quiet.
19688
+ *
19689
+ * Fanned out per camera the difference was free — one query rejected while the
19690
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19691
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19692
+ * got eleven cannot tell which one is missing, or that any is.
19693
+ */
19694
+ var EventDensityForDeviceSchema = object({
19695
+ deviceId: number(),
19696
+ read: _enum(["read", "unreadable"]),
19697
+ buckets: array(EventDensityBucketSchema).readonly()
19698
+ });
19641
19699
  object({
19642
19700
  trackId: string(),
19643
19701
  className: string(),
@@ -19948,12 +20006,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19948
20006
  since: number(),
19949
20007
  until: number(),
19950
20008
  bucketMs: number().int().positive()
19951
- }), array(object({
19952
- bucketStart: number(),
19953
- motion: number().int(),
19954
- object: number().int(),
19955
- audio: number().int()
19956
- })).readonly()), method(object({
20009
+ }), array(EventDensityBucketSchema).readonly()), method(object({
20010
+ deviceIds: array(number()).min(1).max(200),
20011
+ since: number(),
20012
+ until: number(),
20013
+ bucketMs: number().int().positive()
20014
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19957
20015
  deviceId: number(),
19958
20016
  cutoffMs: number()
19959
20017
  }), object({
@@ -27311,6 +27369,39 @@ var RecordingDaysSchema = object({
27311
27369
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
27312
27370
  days: array(number())
27313
27371
  });
27372
+ /**
27373
+ * One camera's row in a `getAvailabilityBatch` answer.
27374
+ *
27375
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
27376
+ * batch collapses the transport, not the work — plus the one thing the singular
27377
+ * method never had to say:
27378
+ *
27379
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
27380
+ * no footage in the window", which is a real claim.
27381
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
27382
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
27383
+ * that emptiness means NOTHING.
27384
+ *
27385
+ * A timeline that renders the second as the first tells an operator there is no
27386
+ * footage when the truth is that nobody looked.
27387
+ */
27388
+ var RecordingAvailabilityForDeviceSchema = object({
27389
+ deviceId: number(),
27390
+ read: _enum(["read", "unreadable"]),
27391
+ ranges: array(RecordingRangeSchema).readonly()
27392
+ });
27393
+ /**
27394
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
27395
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
27396
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
27397
+ * the date-picker's day dots must not spell them the same.
27398
+ */
27399
+ var RecordingDaysForDeviceSchema = object({
27400
+ deviceId: number(),
27401
+ read: _enum(["read", "unreadable"]),
27402
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
27403
+ days: array(number()).readonly()
27404
+ });
27314
27405
  var RecordingManifestSchema = object({
27315
27406
  deviceId: number(),
27316
27407
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -27545,6 +27636,13 @@ method(object({
27545
27636
  }), RecordingAvailabilitySchema, {
27546
27637
  kind: "query",
27547
27638
  auth: "protected"
27639
+ }), method(object({
27640
+ deviceIds: array(number()).min(1).max(200),
27641
+ fromMs: number(),
27642
+ toMs: number()
27643
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
27644
+ kind: "query",
27645
+ auth: "protected"
27548
27646
  }), method(object({
27549
27647
  deviceId: number(),
27550
27648
  fromMs: number(),
@@ -27553,6 +27651,14 @@ method(object({
27553
27651
  }), RecordingDaysSchema, {
27554
27652
  kind: "query",
27555
27653
  auth: "protected"
27654
+ }), method(object({
27655
+ deviceIds: array(number()).min(1).max(200),
27656
+ fromMs: number(),
27657
+ toMs: number(),
27658
+ tzOffsetMinutes: number()
27659
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
27660
+ kind: "query",
27661
+ auth: "protected"
27556
27662
  }), method(object({
27557
27663
  deviceId: number(),
27558
27664
  fromMs: number(),
@@ -31041,6 +31147,12 @@ Object.freeze({
31041
31147
  addonId: null,
31042
31148
  access: "view"
31043
31149
  },
31150
+ "deviceManager.migrateDevice": {
31151
+ capName: "device-manager",
31152
+ capScope: "system",
31153
+ addonId: null,
31154
+ access: "create"
31155
+ },
31044
31156
  "deviceManager.persistConfig": {
31045
31157
  capName: "device-manager",
31046
31158
  capScope: "system",
@@ -32775,6 +32887,12 @@ Object.freeze({
32775
32887
  addonId: null,
32776
32888
  access: "view"
32777
32889
  },
32890
+ "pipelineAnalytics.getEventDensityBatch": {
32891
+ capName: "pipeline-analytics",
32892
+ capScope: "device",
32893
+ addonId: null,
32894
+ access: "view"
32895
+ },
32778
32896
  "pipelineAnalytics.getEventMedia": {
32779
32897
  capName: "pipeline-analytics",
32780
32898
  capScope: "device",
@@ -33897,12 +34015,24 @@ Object.freeze({
33897
34015
  addonId: null,
33898
34016
  access: "view"
33899
34017
  },
34018
+ "recording.getAvailabilityBatch": {
34019
+ capName: "recording",
34020
+ capScope: "system",
34021
+ addonId: null,
34022
+ access: "view"
34023
+ },
33900
34024
  "recording.getDaysWithRecordings": {
33901
34025
  capName: "recording",
33902
34026
  capScope: "system",
33903
34027
  addonId: null,
33904
34028
  access: "view"
33905
34029
  },
34030
+ "recording.getDaysWithRecordingsBatch": {
34031
+ capName: "recording",
34032
+ capScope: "system",
34033
+ addonId: null,
34034
+ access: "view"
34035
+ },
33906
34036
  "recording.getDeviceConfig": {
33907
34037
  capName: "recording",
33908
34038
  capScope: "system",
@@ -36507,6 +36637,11 @@ Object.freeze({
36507
36637
  form: "single",
36508
36638
  optional: false
36509
36639
  }],
36640
+ "pipelineAnalytics.getEventDensityBatch": [{
36641
+ name: "deviceIds",
36642
+ form: "array",
36643
+ optional: false
36644
+ }],
36510
36645
  "pipelineAnalytics.getEventMedia": [{
36511
36646
  name: "deviceId",
36512
36647
  form: "single",
@@ -36922,11 +37057,21 @@ Object.freeze({
36922
37057
  form: "single",
36923
37058
  optional: false
36924
37059
  }],
37060
+ "recording.getAvailabilityBatch": [{
37061
+ name: "deviceIds",
37062
+ form: "array",
37063
+ optional: false
37064
+ }],
36925
37065
  "recording.getDaysWithRecordings": [{
36926
37066
  name: "deviceId",
36927
37067
  form: "single",
36928
37068
  optional: false
36929
37069
  }],
37070
+ "recording.getDaysWithRecordingsBatch": [{
37071
+ name: "deviceIds",
37072
+ form: "array",
37073
+ optional: false
37074
+ }],
36930
37075
  "recording.getDeviceConfig": [{
36931
37076
  name: "deviceId",
36932
37077
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-hap",
3
- "version": "1.2.62",
3
+ "version": "1.2.64",
4
4
  "description": "HomeKit (HAP) exporter for CamStack devices. Publishes each exposed device as its own HomeKit accessory: cameras and doorbells with SRTP streaming, HomeKit Secure Video, motion, two-way audio, PTZ and battery; switches, lights, locks and sensors through a capability→service table.",
5
5
  "keywords": [
6
6
  "camstack",