@camstack/addon-provider-rtsp 1.2.51 → 1.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
@@ -7774,7 +7774,8 @@ var OpsLogReasonSchema = _enum([
7774
7774
  "quota",
7775
7775
  "manual",
7776
7776
  "operator",
7777
- "maintenance"
7777
+ "maintenance",
7778
+ "orphaned-device"
7778
7779
  ]);
7779
7780
  /** One audit row, shared verbatim by both domains. */
7780
7781
  var OpsLogEntrySchema = object({
@@ -13069,10 +13070,39 @@ var DevicePersistConfigPayloadSchema = object({
13069
13070
  deviceId: number(),
13070
13071
  data: record(string(), unknown())
13071
13072
  });
13073
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13074
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13075
+ var MigrateSwitchOutcomeSchema = _enum([
13076
+ "off",
13077
+ "on",
13078
+ "not-offered",
13079
+ "unreachable"
13080
+ ]);
13081
+ var MigrateSwitchReportSchema = object({
13082
+ deviceId: number(),
13083
+ switchId: CameraSwitchIdSchema,
13084
+ outcome: MigrateSwitchOutcomeSchema,
13085
+ detail: string().optional()
13086
+ });
13087
+ var MigrateDeviceResultSchema = object({
13088
+ sourceId: number(),
13089
+ targetId: number(),
13090
+ switches: array(MigrateSwitchReportSchema).readonly(),
13091
+ /** Switches that could NOT be turned off on the source. Empty is the only
13092
+ * value meaning the replaced hardware is quiet. */
13093
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13094
+ swapped: boolean()
13095
+ });
13072
13096
  method(object({
13073
13097
  addonId: string(),
13074
13098
  stableId: string()
13075
- }), 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({
13099
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13100
+ sourceId: number(),
13101
+ targetId: number()
13102
+ }), MigrateDeviceResultSchema, {
13103
+ kind: "mutation",
13104
+ auth: "admin"
13105
+ }), 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({
13076
13106
  deviceId: number(),
13077
13107
  name: string()
13078
13108
  }), _void(), {
@@ -19247,6 +19277,34 @@ var KeyEventsForDeviceSchema = object({
19247
19277
  deviceId: number(),
19248
19278
  events: array(KeyEventSchema).readonly()
19249
19279
  });
19280
+ /** One non-empty bucket of the timeline histogram. */
19281
+ var EventDensityBucketSchema = object({
19282
+ bucketStart: number(),
19283
+ motion: number().int(),
19284
+ object: number().int(),
19285
+ audio: number().int()
19286
+ });
19287
+ /**
19288
+ * One camera's row in a `getEventDensityBatch` answer.
19289
+ *
19290
+ * TWO facts, and the timeline draws them differently:
19291
+ *
19292
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19293
+ * claim: read, and nothing happened in the window.
19294
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19295
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19296
+ * not quiet.
19297
+ *
19298
+ * Fanned out per camera the difference was free — one query rejected while the
19299
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19300
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19301
+ * got eleven cannot tell which one is missing, or that any is.
19302
+ */
19303
+ var EventDensityForDeviceSchema = object({
19304
+ deviceId: number(),
19305
+ read: _enum(["read", "unreadable"]),
19306
+ buckets: array(EventDensityBucketSchema).readonly()
19307
+ });
19250
19308
  object({
19251
19309
  trackId: string(),
19252
19310
  className: string(),
@@ -19557,12 +19615,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19557
19615
  since: number(),
19558
19616
  until: number(),
19559
19617
  bucketMs: number().int().positive()
19560
- }), array(object({
19561
- bucketStart: number(),
19562
- motion: number().int(),
19563
- object: number().int(),
19564
- audio: number().int()
19565
- })).readonly()), method(object({
19618
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19619
+ deviceIds: array(number()).min(1).max(200),
19620
+ since: number(),
19621
+ until: number(),
19622
+ bucketMs: number().int().positive()
19623
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19566
19624
  deviceId: number(),
19567
19625
  cutoffMs: number()
19568
19626
  }), object({
@@ -28699,6 +28757,39 @@ var RecordingDaysSchema = object({
28699
28757
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28700
28758
  days: array(number())
28701
28759
  });
28760
+ /**
28761
+ * One camera's row in a `getAvailabilityBatch` answer.
28762
+ *
28763
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28764
+ * batch collapses the transport, not the work — plus the one thing the singular
28765
+ * method never had to say:
28766
+ *
28767
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28768
+ * no footage in the window", which is a real claim.
28769
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28770
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28771
+ * that emptiness means NOTHING.
28772
+ *
28773
+ * A timeline that renders the second as the first tells an operator there is no
28774
+ * footage when the truth is that nobody looked.
28775
+ */
28776
+ var RecordingAvailabilityForDeviceSchema = object({
28777
+ deviceId: number(),
28778
+ read: _enum(["read", "unreadable"]),
28779
+ ranges: array(RecordingRangeSchema).readonly()
28780
+ });
28781
+ /**
28782
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28783
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28784
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28785
+ * the date-picker's day dots must not spell them the same.
28786
+ */
28787
+ var RecordingDaysForDeviceSchema = object({
28788
+ deviceId: number(),
28789
+ read: _enum(["read", "unreadable"]),
28790
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28791
+ days: array(number()).readonly()
28792
+ });
28702
28793
  var RecordingManifestSchema = object({
28703
28794
  deviceId: number(),
28704
28795
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28933,6 +29024,13 @@ method(object({
28933
29024
  }), RecordingAvailabilitySchema, {
28934
29025
  kind: "query",
28935
29026
  auth: "protected"
29027
+ }), method(object({
29028
+ deviceIds: array(number()).min(1).max(200),
29029
+ fromMs: number(),
29030
+ toMs: number()
29031
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
29032
+ kind: "query",
29033
+ auth: "protected"
28936
29034
  }), method(object({
28937
29035
  deviceId: number(),
28938
29036
  fromMs: number(),
@@ -28941,6 +29039,14 @@ method(object({
28941
29039
  }), RecordingDaysSchema, {
28942
29040
  kind: "query",
28943
29041
  auth: "protected"
29042
+ }), method(object({
29043
+ deviceIds: array(number()).min(1).max(200),
29044
+ fromMs: number(),
29045
+ toMs: number(),
29046
+ tzOffsetMinutes: number()
29047
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
29048
+ kind: "query",
29049
+ auth: "protected"
28944
29050
  }), method(object({
28945
29051
  deviceId: number(),
28946
29052
  fromMs: number(),
@@ -34169,6 +34275,12 @@ Object.freeze({
34169
34275
  addonId: null,
34170
34276
  access: "view"
34171
34277
  },
34278
+ "deviceManager.migrateDevice": {
34279
+ capName: "device-manager",
34280
+ capScope: "system",
34281
+ addonId: null,
34282
+ access: "create"
34283
+ },
34172
34284
  "deviceManager.persistConfig": {
34173
34285
  capName: "device-manager",
34174
34286
  capScope: "system",
@@ -35903,6 +36015,12 @@ Object.freeze({
35903
36015
  addonId: null,
35904
36016
  access: "view"
35905
36017
  },
36018
+ "pipelineAnalytics.getEventDensityBatch": {
36019
+ capName: "pipeline-analytics",
36020
+ capScope: "device",
36021
+ addonId: null,
36022
+ access: "view"
36023
+ },
35906
36024
  "pipelineAnalytics.getEventMedia": {
35907
36025
  capName: "pipeline-analytics",
35908
36026
  capScope: "device",
@@ -37025,12 +37143,24 @@ Object.freeze({
37025
37143
  addonId: null,
37026
37144
  access: "view"
37027
37145
  },
37146
+ "recording.getAvailabilityBatch": {
37147
+ capName: "recording",
37148
+ capScope: "system",
37149
+ addonId: null,
37150
+ access: "view"
37151
+ },
37028
37152
  "recording.getDaysWithRecordings": {
37029
37153
  capName: "recording",
37030
37154
  capScope: "system",
37031
37155
  addonId: null,
37032
37156
  access: "view"
37033
37157
  },
37158
+ "recording.getDaysWithRecordingsBatch": {
37159
+ capName: "recording",
37160
+ capScope: "system",
37161
+ addonId: null,
37162
+ access: "view"
37163
+ },
37034
37164
  "recording.getDeviceConfig": {
37035
37165
  capName: "recording",
37036
37166
  capScope: "system",
@@ -39635,6 +39765,11 @@ Object.freeze({
39635
39765
  form: "single",
39636
39766
  optional: false
39637
39767
  }],
39768
+ "pipelineAnalytics.getEventDensityBatch": [{
39769
+ name: "deviceIds",
39770
+ form: "array",
39771
+ optional: false
39772
+ }],
39638
39773
  "pipelineAnalytics.getEventMedia": [{
39639
39774
  name: "deviceId",
39640
39775
  form: "single",
@@ -40050,11 +40185,21 @@ Object.freeze({
40050
40185
  form: "single",
40051
40186
  optional: false
40052
40187
  }],
40188
+ "recording.getAvailabilityBatch": [{
40189
+ name: "deviceIds",
40190
+ form: "array",
40191
+ optional: false
40192
+ }],
40053
40193
  "recording.getDaysWithRecordings": [{
40054
40194
  name: "deviceId",
40055
40195
  form: "single",
40056
40196
  optional: false
40057
40197
  }],
40198
+ "recording.getDaysWithRecordingsBatch": [{
40199
+ name: "deviceIds",
40200
+ form: "array",
40201
+ optional: false
40202
+ }],
40058
40203
  "recording.getDeviceConfig": [{
40059
40204
  name: "deviceId",
40060
40205
  form: "single",
package/dist/addon.mjs CHANGED
@@ -7750,7 +7750,8 @@ var OpsLogReasonSchema = _enum([
7750
7750
  "quota",
7751
7751
  "manual",
7752
7752
  "operator",
7753
- "maintenance"
7753
+ "maintenance",
7754
+ "orphaned-device"
7754
7755
  ]);
7755
7756
  /** One audit row, shared verbatim by both domains. */
7756
7757
  var OpsLogEntrySchema = object({
@@ -13045,10 +13046,39 @@ var DevicePersistConfigPayloadSchema = object({
13045
13046
  deviceId: number(),
13046
13047
  data: record(string(), unknown())
13047
13048
  });
13049
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13050
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13051
+ var MigrateSwitchOutcomeSchema = _enum([
13052
+ "off",
13053
+ "on",
13054
+ "not-offered",
13055
+ "unreachable"
13056
+ ]);
13057
+ var MigrateSwitchReportSchema = object({
13058
+ deviceId: number(),
13059
+ switchId: CameraSwitchIdSchema,
13060
+ outcome: MigrateSwitchOutcomeSchema,
13061
+ detail: string().optional()
13062
+ });
13063
+ var MigrateDeviceResultSchema = object({
13064
+ sourceId: number(),
13065
+ targetId: number(),
13066
+ switches: array(MigrateSwitchReportSchema).readonly(),
13067
+ /** Switches that could NOT be turned off on the source. Empty is the only
13068
+ * value meaning the replaced hardware is quiet. */
13069
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13070
+ swapped: boolean()
13071
+ });
13048
13072
  method(object({
13049
13073
  addonId: string(),
13050
13074
  stableId: string()
13051
- }), 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({
13075
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13076
+ sourceId: number(),
13077
+ targetId: number()
13078
+ }), MigrateDeviceResultSchema, {
13079
+ kind: "mutation",
13080
+ auth: "admin"
13081
+ }), 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({
13052
13082
  deviceId: number(),
13053
13083
  name: string()
13054
13084
  }), _void(), {
@@ -19223,6 +19253,34 @@ var KeyEventsForDeviceSchema = object({
19223
19253
  deviceId: number(),
19224
19254
  events: array(KeyEventSchema).readonly()
19225
19255
  });
19256
+ /** One non-empty bucket of the timeline histogram. */
19257
+ var EventDensityBucketSchema = object({
19258
+ bucketStart: number(),
19259
+ motion: number().int(),
19260
+ object: number().int(),
19261
+ audio: number().int()
19262
+ });
19263
+ /**
19264
+ * One camera's row in a `getEventDensityBatch` answer.
19265
+ *
19266
+ * TWO facts, and the timeline draws them differently:
19267
+ *
19268
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19269
+ * claim: read, and nothing happened in the window.
19270
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19271
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19272
+ * not quiet.
19273
+ *
19274
+ * Fanned out per camera the difference was free — one query rejected while the
19275
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19276
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19277
+ * got eleven cannot tell which one is missing, or that any is.
19278
+ */
19279
+ var EventDensityForDeviceSchema = object({
19280
+ deviceId: number(),
19281
+ read: _enum(["read", "unreadable"]),
19282
+ buckets: array(EventDensityBucketSchema).readonly()
19283
+ });
19226
19284
  object({
19227
19285
  trackId: string(),
19228
19286
  className: string(),
@@ -19533,12 +19591,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19533
19591
  since: number(),
19534
19592
  until: number(),
19535
19593
  bucketMs: number().int().positive()
19536
- }), array(object({
19537
- bucketStart: number(),
19538
- motion: number().int(),
19539
- object: number().int(),
19540
- audio: number().int()
19541
- })).readonly()), method(object({
19594
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19595
+ deviceIds: array(number()).min(1).max(200),
19596
+ since: number(),
19597
+ until: number(),
19598
+ bucketMs: number().int().positive()
19599
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19542
19600
  deviceId: number(),
19543
19601
  cutoffMs: number()
19544
19602
  }), object({
@@ -28675,6 +28733,39 @@ var RecordingDaysSchema = object({
28675
28733
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28676
28734
  days: array(number())
28677
28735
  });
28736
+ /**
28737
+ * One camera's row in a `getAvailabilityBatch` answer.
28738
+ *
28739
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28740
+ * batch collapses the transport, not the work — plus the one thing the singular
28741
+ * method never had to say:
28742
+ *
28743
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28744
+ * no footage in the window", which is a real claim.
28745
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28746
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28747
+ * that emptiness means NOTHING.
28748
+ *
28749
+ * A timeline that renders the second as the first tells an operator there is no
28750
+ * footage when the truth is that nobody looked.
28751
+ */
28752
+ var RecordingAvailabilityForDeviceSchema = object({
28753
+ deviceId: number(),
28754
+ read: _enum(["read", "unreadable"]),
28755
+ ranges: array(RecordingRangeSchema).readonly()
28756
+ });
28757
+ /**
28758
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28759
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28760
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28761
+ * the date-picker's day dots must not spell them the same.
28762
+ */
28763
+ var RecordingDaysForDeviceSchema = object({
28764
+ deviceId: number(),
28765
+ read: _enum(["read", "unreadable"]),
28766
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28767
+ days: array(number()).readonly()
28768
+ });
28678
28769
  var RecordingManifestSchema = object({
28679
28770
  deviceId: number(),
28680
28771
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28909,6 +29000,13 @@ method(object({
28909
29000
  }), RecordingAvailabilitySchema, {
28910
29001
  kind: "query",
28911
29002
  auth: "protected"
29003
+ }), method(object({
29004
+ deviceIds: array(number()).min(1).max(200),
29005
+ fromMs: number(),
29006
+ toMs: number()
29007
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
29008
+ kind: "query",
29009
+ auth: "protected"
28912
29010
  }), method(object({
28913
29011
  deviceId: number(),
28914
29012
  fromMs: number(),
@@ -28917,6 +29015,14 @@ method(object({
28917
29015
  }), RecordingDaysSchema, {
28918
29016
  kind: "query",
28919
29017
  auth: "protected"
29018
+ }), method(object({
29019
+ deviceIds: array(number()).min(1).max(200),
29020
+ fromMs: number(),
29021
+ toMs: number(),
29022
+ tzOffsetMinutes: number()
29023
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
29024
+ kind: "query",
29025
+ auth: "protected"
28920
29026
  }), method(object({
28921
29027
  deviceId: number(),
28922
29028
  fromMs: number(),
@@ -34145,6 +34251,12 @@ Object.freeze({
34145
34251
  addonId: null,
34146
34252
  access: "view"
34147
34253
  },
34254
+ "deviceManager.migrateDevice": {
34255
+ capName: "device-manager",
34256
+ capScope: "system",
34257
+ addonId: null,
34258
+ access: "create"
34259
+ },
34148
34260
  "deviceManager.persistConfig": {
34149
34261
  capName: "device-manager",
34150
34262
  capScope: "system",
@@ -35879,6 +35991,12 @@ Object.freeze({
35879
35991
  addonId: null,
35880
35992
  access: "view"
35881
35993
  },
35994
+ "pipelineAnalytics.getEventDensityBatch": {
35995
+ capName: "pipeline-analytics",
35996
+ capScope: "device",
35997
+ addonId: null,
35998
+ access: "view"
35999
+ },
35882
36000
  "pipelineAnalytics.getEventMedia": {
35883
36001
  capName: "pipeline-analytics",
35884
36002
  capScope: "device",
@@ -37001,12 +37119,24 @@ Object.freeze({
37001
37119
  addonId: null,
37002
37120
  access: "view"
37003
37121
  },
37122
+ "recording.getAvailabilityBatch": {
37123
+ capName: "recording",
37124
+ capScope: "system",
37125
+ addonId: null,
37126
+ access: "view"
37127
+ },
37004
37128
  "recording.getDaysWithRecordings": {
37005
37129
  capName: "recording",
37006
37130
  capScope: "system",
37007
37131
  addonId: null,
37008
37132
  access: "view"
37009
37133
  },
37134
+ "recording.getDaysWithRecordingsBatch": {
37135
+ capName: "recording",
37136
+ capScope: "system",
37137
+ addonId: null,
37138
+ access: "view"
37139
+ },
37010
37140
  "recording.getDeviceConfig": {
37011
37141
  capName: "recording",
37012
37142
  capScope: "system",
@@ -39611,6 +39741,11 @@ Object.freeze({
39611
39741
  form: "single",
39612
39742
  optional: false
39613
39743
  }],
39744
+ "pipelineAnalytics.getEventDensityBatch": [{
39745
+ name: "deviceIds",
39746
+ form: "array",
39747
+ optional: false
39748
+ }],
39614
39749
  "pipelineAnalytics.getEventMedia": [{
39615
39750
  name: "deviceId",
39616
39751
  form: "single",
@@ -40026,11 +40161,21 @@ Object.freeze({
40026
40161
  form: "single",
40027
40162
  optional: false
40028
40163
  }],
40164
+ "recording.getAvailabilityBatch": [{
40165
+ name: "deviceIds",
40166
+ form: "array",
40167
+ optional: false
40168
+ }],
40029
40169
  "recording.getDaysWithRecordings": [{
40030
40170
  name: "deviceId",
40031
40171
  form: "single",
40032
40172
  optional: false
40033
40173
  }],
40174
+ "recording.getDaysWithRecordingsBatch": [{
40175
+ name: "deviceIds",
40176
+ form: "array",
40177
+ optional: false
40178
+ }],
40034
40179
  "recording.getDeviceConfig": [{
40035
40180
  name: "deviceId",
40036
40181
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-rtsp",
3
- "version": "1.2.51",
3
+ "version": "1.2.53",
4
4
  "description": "Generic RTSP camera device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",