@camstack/addon-provider-unifi 0.2.51 → 0.2.53

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
@@ -7718,7 +7718,8 @@ var OpsLogReasonSchema = _enum([
7718
7718
  "quota",
7719
7719
  "manual",
7720
7720
  "operator",
7721
- "maintenance"
7721
+ "maintenance",
7722
+ "orphaned-device"
7722
7723
  ]);
7723
7724
  /** One audit row, shared verbatim by both domains. */
7724
7725
  var OpsLogEntrySchema = object({
@@ -13013,10 +13014,39 @@ var DevicePersistConfigPayloadSchema = object({
13013
13014
  deviceId: number(),
13014
13015
  data: record(string(), unknown())
13015
13016
  });
13017
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13018
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13019
+ var MigrateSwitchOutcomeSchema = _enum([
13020
+ "off",
13021
+ "on",
13022
+ "not-offered",
13023
+ "unreachable"
13024
+ ]);
13025
+ var MigrateSwitchReportSchema = object({
13026
+ deviceId: number(),
13027
+ switchId: CameraSwitchIdSchema,
13028
+ outcome: MigrateSwitchOutcomeSchema,
13029
+ detail: string().optional()
13030
+ });
13031
+ var MigrateDeviceResultSchema = object({
13032
+ sourceId: number(),
13033
+ targetId: number(),
13034
+ switches: array(MigrateSwitchReportSchema).readonly(),
13035
+ /** Switches that could NOT be turned off on the source. Empty is the only
13036
+ * value meaning the replaced hardware is quiet. */
13037
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13038
+ swapped: boolean()
13039
+ });
13016
13040
  method(object({
13017
13041
  addonId: string(),
13018
13042
  stableId: string()
13019
- }), 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({
13043
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13044
+ sourceId: number(),
13045
+ targetId: number()
13046
+ }), MigrateDeviceResultSchema, {
13047
+ kind: "mutation",
13048
+ auth: "admin"
13049
+ }), 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({
13020
13050
  deviceId: number(),
13021
13051
  name: string()
13022
13052
  }), _void(), {
@@ -19191,6 +19221,34 @@ var KeyEventsForDeviceSchema = object({
19191
19221
  deviceId: number(),
19192
19222
  events: array(KeyEventSchema).readonly()
19193
19223
  });
19224
+ /** One non-empty bucket of the timeline histogram. */
19225
+ var EventDensityBucketSchema = object({
19226
+ bucketStart: number(),
19227
+ motion: number().int(),
19228
+ object: number().int(),
19229
+ audio: number().int()
19230
+ });
19231
+ /**
19232
+ * One camera's row in a `getEventDensityBatch` answer.
19233
+ *
19234
+ * TWO facts, and the timeline draws them differently:
19235
+ *
19236
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19237
+ * claim: read, and nothing happened in the window.
19238
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19239
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19240
+ * not quiet.
19241
+ *
19242
+ * Fanned out per camera the difference was free — one query rejected while the
19243
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19244
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19245
+ * got eleven cannot tell which one is missing, or that any is.
19246
+ */
19247
+ var EventDensityForDeviceSchema = object({
19248
+ deviceId: number(),
19249
+ read: _enum(["read", "unreadable"]),
19250
+ buckets: array(EventDensityBucketSchema).readonly()
19251
+ });
19194
19252
  object({
19195
19253
  trackId: string(),
19196
19254
  className: string(),
@@ -19501,12 +19559,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19501
19559
  since: number(),
19502
19560
  until: number(),
19503
19561
  bucketMs: number().int().positive()
19504
- }), array(object({
19505
- bucketStart: number(),
19506
- motion: number().int(),
19507
- object: number().int(),
19508
- audio: number().int()
19509
- })).readonly()), method(object({
19562
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19563
+ deviceIds: array(number()).min(1).max(200),
19564
+ since: number(),
19565
+ until: number(),
19566
+ bucketMs: number().int().positive()
19567
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19510
19568
  deviceId: number(),
19511
19569
  cutoffMs: number()
19512
19570
  }), object({
@@ -28548,6 +28606,39 @@ var RecordingDaysSchema = object({
28548
28606
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28549
28607
  days: array(number())
28550
28608
  });
28609
+ /**
28610
+ * One camera's row in a `getAvailabilityBatch` answer.
28611
+ *
28612
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28613
+ * batch collapses the transport, not the work — plus the one thing the singular
28614
+ * method never had to say:
28615
+ *
28616
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28617
+ * no footage in the window", which is a real claim.
28618
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28619
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28620
+ * that emptiness means NOTHING.
28621
+ *
28622
+ * A timeline that renders the second as the first tells an operator there is no
28623
+ * footage when the truth is that nobody looked.
28624
+ */
28625
+ var RecordingAvailabilityForDeviceSchema = object({
28626
+ deviceId: number(),
28627
+ read: _enum(["read", "unreadable"]),
28628
+ ranges: array(RecordingRangeSchema).readonly()
28629
+ });
28630
+ /**
28631
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28632
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28633
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28634
+ * the date-picker's day dots must not spell them the same.
28635
+ */
28636
+ var RecordingDaysForDeviceSchema = object({
28637
+ deviceId: number(),
28638
+ read: _enum(["read", "unreadable"]),
28639
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28640
+ days: array(number()).readonly()
28641
+ });
28551
28642
  var RecordingManifestSchema = object({
28552
28643
  deviceId: number(),
28553
28644
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28782,6 +28873,13 @@ method(object({
28782
28873
  }), RecordingAvailabilitySchema, {
28783
28874
  kind: "query",
28784
28875
  auth: "protected"
28876
+ }), method(object({
28877
+ deviceIds: array(number()).min(1).max(200),
28878
+ fromMs: number(),
28879
+ toMs: number()
28880
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28881
+ kind: "query",
28882
+ auth: "protected"
28785
28883
  }), method(object({
28786
28884
  deviceId: number(),
28787
28885
  fromMs: number(),
@@ -28790,6 +28888,14 @@ method(object({
28790
28888
  }), RecordingDaysSchema, {
28791
28889
  kind: "query",
28792
28890
  auth: "protected"
28891
+ }), method(object({
28892
+ deviceIds: array(number()).min(1).max(200),
28893
+ fromMs: number(),
28894
+ toMs: number(),
28895
+ tzOffsetMinutes: number()
28896
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28897
+ kind: "query",
28898
+ auth: "protected"
28793
28899
  }), method(object({
28794
28900
  deviceId: number(),
28795
28901
  fromMs: number(),
@@ -33952,6 +34058,12 @@ Object.freeze({
33952
34058
  addonId: null,
33953
34059
  access: "view"
33954
34060
  },
34061
+ "deviceManager.migrateDevice": {
34062
+ capName: "device-manager",
34063
+ capScope: "system",
34064
+ addonId: null,
34065
+ access: "create"
34066
+ },
33955
34067
  "deviceManager.persistConfig": {
33956
34068
  capName: "device-manager",
33957
34069
  capScope: "system",
@@ -35686,6 +35798,12 @@ Object.freeze({
35686
35798
  addonId: null,
35687
35799
  access: "view"
35688
35800
  },
35801
+ "pipelineAnalytics.getEventDensityBatch": {
35802
+ capName: "pipeline-analytics",
35803
+ capScope: "device",
35804
+ addonId: null,
35805
+ access: "view"
35806
+ },
35689
35807
  "pipelineAnalytics.getEventMedia": {
35690
35808
  capName: "pipeline-analytics",
35691
35809
  capScope: "device",
@@ -36808,12 +36926,24 @@ Object.freeze({
36808
36926
  addonId: null,
36809
36927
  access: "view"
36810
36928
  },
36929
+ "recording.getAvailabilityBatch": {
36930
+ capName: "recording",
36931
+ capScope: "system",
36932
+ addonId: null,
36933
+ access: "view"
36934
+ },
36811
36935
  "recording.getDaysWithRecordings": {
36812
36936
  capName: "recording",
36813
36937
  capScope: "system",
36814
36938
  addonId: null,
36815
36939
  access: "view"
36816
36940
  },
36941
+ "recording.getDaysWithRecordingsBatch": {
36942
+ capName: "recording",
36943
+ capScope: "system",
36944
+ addonId: null,
36945
+ access: "view"
36946
+ },
36817
36947
  "recording.getDeviceConfig": {
36818
36948
  capName: "recording",
36819
36949
  capScope: "system",
@@ -39418,6 +39548,11 @@ Object.freeze({
39418
39548
  form: "single",
39419
39549
  optional: false
39420
39550
  }],
39551
+ "pipelineAnalytics.getEventDensityBatch": [{
39552
+ name: "deviceIds",
39553
+ form: "array",
39554
+ optional: false
39555
+ }],
39421
39556
  "pipelineAnalytics.getEventMedia": [{
39422
39557
  name: "deviceId",
39423
39558
  form: "single",
@@ -39833,11 +39968,21 @@ Object.freeze({
39833
39968
  form: "single",
39834
39969
  optional: false
39835
39970
  }],
39971
+ "recording.getAvailabilityBatch": [{
39972
+ name: "deviceIds",
39973
+ form: "array",
39974
+ optional: false
39975
+ }],
39836
39976
  "recording.getDaysWithRecordings": [{
39837
39977
  name: "deviceId",
39838
39978
  form: "single",
39839
39979
  optional: false
39840
39980
  }],
39981
+ "recording.getDaysWithRecordingsBatch": [{
39982
+ name: "deviceIds",
39983
+ form: "array",
39984
+ optional: false
39985
+ }],
39841
39986
  "recording.getDeviceConfig": [{
39842
39987
  name: "deviceId",
39843
39988
  form: "single",
package/dist/addon.mjs CHANGED
@@ -7717,7 +7717,8 @@ var OpsLogReasonSchema = _enum([
7717
7717
  "quota",
7718
7718
  "manual",
7719
7719
  "operator",
7720
- "maintenance"
7720
+ "maintenance",
7721
+ "orphaned-device"
7721
7722
  ]);
7722
7723
  /** One audit row, shared verbatim by both domains. */
7723
7724
  var OpsLogEntrySchema = object({
@@ -13012,10 +13013,39 @@ var DevicePersistConfigPayloadSchema = object({
13012
13013
  deviceId: number(),
13013
13014
  data: record(string(), unknown())
13014
13015
  });
13016
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13017
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13018
+ var MigrateSwitchOutcomeSchema = _enum([
13019
+ "off",
13020
+ "on",
13021
+ "not-offered",
13022
+ "unreachable"
13023
+ ]);
13024
+ var MigrateSwitchReportSchema = object({
13025
+ deviceId: number(),
13026
+ switchId: CameraSwitchIdSchema,
13027
+ outcome: MigrateSwitchOutcomeSchema,
13028
+ detail: string().optional()
13029
+ });
13030
+ var MigrateDeviceResultSchema = object({
13031
+ sourceId: number(),
13032
+ targetId: number(),
13033
+ switches: array(MigrateSwitchReportSchema).readonly(),
13034
+ /** Switches that could NOT be turned off on the source. Empty is the only
13035
+ * value meaning the replaced hardware is quiet. */
13036
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13037
+ swapped: boolean()
13038
+ });
13015
13039
  method(object({
13016
13040
  addonId: string(),
13017
13041
  stableId: string()
13018
- }), 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({
13042
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13043
+ sourceId: number(),
13044
+ targetId: number()
13045
+ }), MigrateDeviceResultSchema, {
13046
+ kind: "mutation",
13047
+ auth: "admin"
13048
+ }), 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({
13019
13049
  deviceId: number(),
13020
13050
  name: string()
13021
13051
  }), _void(), {
@@ -19190,6 +19220,34 @@ var KeyEventsForDeviceSchema = object({
19190
19220
  deviceId: number(),
19191
19221
  events: array(KeyEventSchema).readonly()
19192
19222
  });
19223
+ /** One non-empty bucket of the timeline histogram. */
19224
+ var EventDensityBucketSchema = object({
19225
+ bucketStart: number(),
19226
+ motion: number().int(),
19227
+ object: number().int(),
19228
+ audio: number().int()
19229
+ });
19230
+ /**
19231
+ * One camera's row in a `getEventDensityBatch` answer.
19232
+ *
19233
+ * TWO facts, and the timeline draws them differently:
19234
+ *
19235
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19236
+ * claim: read, and nothing happened in the window.
19237
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19238
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19239
+ * not quiet.
19240
+ *
19241
+ * Fanned out per camera the difference was free — one query rejected while the
19242
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19243
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19244
+ * got eleven cannot tell which one is missing, or that any is.
19245
+ */
19246
+ var EventDensityForDeviceSchema = object({
19247
+ deviceId: number(),
19248
+ read: _enum(["read", "unreadable"]),
19249
+ buckets: array(EventDensityBucketSchema).readonly()
19250
+ });
19193
19251
  object({
19194
19252
  trackId: string(),
19195
19253
  className: string(),
@@ -19500,12 +19558,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19500
19558
  since: number(),
19501
19559
  until: number(),
19502
19560
  bucketMs: number().int().positive()
19503
- }), array(object({
19504
- bucketStart: number(),
19505
- motion: number().int(),
19506
- object: number().int(),
19507
- audio: number().int()
19508
- })).readonly()), method(object({
19561
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19562
+ deviceIds: array(number()).min(1).max(200),
19563
+ since: number(),
19564
+ until: number(),
19565
+ bucketMs: number().int().positive()
19566
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19509
19567
  deviceId: number(),
19510
19568
  cutoffMs: number()
19511
19569
  }), object({
@@ -28547,6 +28605,39 @@ var RecordingDaysSchema = object({
28547
28605
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28548
28606
  days: array(number())
28549
28607
  });
28608
+ /**
28609
+ * One camera's row in a `getAvailabilityBatch` answer.
28610
+ *
28611
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28612
+ * batch collapses the transport, not the work — plus the one thing the singular
28613
+ * method never had to say:
28614
+ *
28615
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28616
+ * no footage in the window", which is a real claim.
28617
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28618
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28619
+ * that emptiness means NOTHING.
28620
+ *
28621
+ * A timeline that renders the second as the first tells an operator there is no
28622
+ * footage when the truth is that nobody looked.
28623
+ */
28624
+ var RecordingAvailabilityForDeviceSchema = object({
28625
+ deviceId: number(),
28626
+ read: _enum(["read", "unreadable"]),
28627
+ ranges: array(RecordingRangeSchema).readonly()
28628
+ });
28629
+ /**
28630
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28631
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28632
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28633
+ * the date-picker's day dots must not spell them the same.
28634
+ */
28635
+ var RecordingDaysForDeviceSchema = object({
28636
+ deviceId: number(),
28637
+ read: _enum(["read", "unreadable"]),
28638
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28639
+ days: array(number()).readonly()
28640
+ });
28550
28641
  var RecordingManifestSchema = object({
28551
28642
  deviceId: number(),
28552
28643
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28781,6 +28872,13 @@ method(object({
28781
28872
  }), RecordingAvailabilitySchema, {
28782
28873
  kind: "query",
28783
28874
  auth: "protected"
28875
+ }), method(object({
28876
+ deviceIds: array(number()).min(1).max(200),
28877
+ fromMs: number(),
28878
+ toMs: number()
28879
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28880
+ kind: "query",
28881
+ auth: "protected"
28784
28882
  }), method(object({
28785
28883
  deviceId: number(),
28786
28884
  fromMs: number(),
@@ -28789,6 +28887,14 @@ method(object({
28789
28887
  }), RecordingDaysSchema, {
28790
28888
  kind: "query",
28791
28889
  auth: "protected"
28890
+ }), method(object({
28891
+ deviceIds: array(number()).min(1).max(200),
28892
+ fromMs: number(),
28893
+ toMs: number(),
28894
+ tzOffsetMinutes: number()
28895
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28896
+ kind: "query",
28897
+ auth: "protected"
28792
28898
  }), method(object({
28793
28899
  deviceId: number(),
28794
28900
  fromMs: number(),
@@ -33951,6 +34057,12 @@ Object.freeze({
33951
34057
  addonId: null,
33952
34058
  access: "view"
33953
34059
  },
34060
+ "deviceManager.migrateDevice": {
34061
+ capName: "device-manager",
34062
+ capScope: "system",
34063
+ addonId: null,
34064
+ access: "create"
34065
+ },
33954
34066
  "deviceManager.persistConfig": {
33955
34067
  capName: "device-manager",
33956
34068
  capScope: "system",
@@ -35685,6 +35797,12 @@ Object.freeze({
35685
35797
  addonId: null,
35686
35798
  access: "view"
35687
35799
  },
35800
+ "pipelineAnalytics.getEventDensityBatch": {
35801
+ capName: "pipeline-analytics",
35802
+ capScope: "device",
35803
+ addonId: null,
35804
+ access: "view"
35805
+ },
35688
35806
  "pipelineAnalytics.getEventMedia": {
35689
35807
  capName: "pipeline-analytics",
35690
35808
  capScope: "device",
@@ -36807,12 +36925,24 @@ Object.freeze({
36807
36925
  addonId: null,
36808
36926
  access: "view"
36809
36927
  },
36928
+ "recording.getAvailabilityBatch": {
36929
+ capName: "recording",
36930
+ capScope: "system",
36931
+ addonId: null,
36932
+ access: "view"
36933
+ },
36810
36934
  "recording.getDaysWithRecordings": {
36811
36935
  capName: "recording",
36812
36936
  capScope: "system",
36813
36937
  addonId: null,
36814
36938
  access: "view"
36815
36939
  },
36940
+ "recording.getDaysWithRecordingsBatch": {
36941
+ capName: "recording",
36942
+ capScope: "system",
36943
+ addonId: null,
36944
+ access: "view"
36945
+ },
36816
36946
  "recording.getDeviceConfig": {
36817
36947
  capName: "recording",
36818
36948
  capScope: "system",
@@ -39417,6 +39547,11 @@ Object.freeze({
39417
39547
  form: "single",
39418
39548
  optional: false
39419
39549
  }],
39550
+ "pipelineAnalytics.getEventDensityBatch": [{
39551
+ name: "deviceIds",
39552
+ form: "array",
39553
+ optional: false
39554
+ }],
39420
39555
  "pipelineAnalytics.getEventMedia": [{
39421
39556
  name: "deviceId",
39422
39557
  form: "single",
@@ -39832,11 +39967,21 @@ Object.freeze({
39832
39967
  form: "single",
39833
39968
  optional: false
39834
39969
  }],
39970
+ "recording.getAvailabilityBatch": [{
39971
+ name: "deviceIds",
39972
+ form: "array",
39973
+ optional: false
39974
+ }],
39835
39975
  "recording.getDaysWithRecordings": [{
39836
39976
  name: "deviceId",
39837
39977
  form: "single",
39838
39978
  optional: false
39839
39979
  }],
39980
+ "recording.getDaysWithRecordingsBatch": [{
39981
+ name: "deviceIds",
39982
+ form: "array",
39983
+ optional: false
39984
+ }],
39840
39985
  "recording.getDeviceConfig": [{
39841
39986
  name: "deviceId",
39842
39987
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-unifi",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "UniFi Network controller device-provider addon for CamStack — local-controller infra switches/APs (as containers) + network-client presence. NO cameras/Protect.",
5
5
  "keywords": [
6
6
  "camstack",