@camstack/addon-provider-velux 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({
@@ -28531,6 +28589,39 @@ var RecordingDaysSchema = object({
28531
28589
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28532
28590
  days: array(number())
28533
28591
  });
28592
+ /**
28593
+ * One camera's row in a `getAvailabilityBatch` answer.
28594
+ *
28595
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28596
+ * batch collapses the transport, not the work — plus the one thing the singular
28597
+ * method never had to say:
28598
+ *
28599
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28600
+ * no footage in the window", which is a real claim.
28601
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28602
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28603
+ * that emptiness means NOTHING.
28604
+ *
28605
+ * A timeline that renders the second as the first tells an operator there is no
28606
+ * footage when the truth is that nobody looked.
28607
+ */
28608
+ var RecordingAvailabilityForDeviceSchema = object({
28609
+ deviceId: number(),
28610
+ read: _enum(["read", "unreadable"]),
28611
+ ranges: array(RecordingRangeSchema).readonly()
28612
+ });
28613
+ /**
28614
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28615
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28616
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28617
+ * the date-picker's day dots must not spell them the same.
28618
+ */
28619
+ var RecordingDaysForDeviceSchema = object({
28620
+ deviceId: number(),
28621
+ read: _enum(["read", "unreadable"]),
28622
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28623
+ days: array(number()).readonly()
28624
+ });
28534
28625
  var RecordingManifestSchema = object({
28535
28626
  deviceId: number(),
28536
28627
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28765,6 +28856,13 @@ method(object({
28765
28856
  }), RecordingAvailabilitySchema, {
28766
28857
  kind: "query",
28767
28858
  auth: "protected"
28859
+ }), method(object({
28860
+ deviceIds: array(number()).min(1).max(200),
28861
+ fromMs: number(),
28862
+ toMs: number()
28863
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28864
+ kind: "query",
28865
+ auth: "protected"
28768
28866
  }), method(object({
28769
28867
  deviceId: number(),
28770
28868
  fromMs: number(),
@@ -28773,6 +28871,14 @@ method(object({
28773
28871
  }), RecordingDaysSchema, {
28774
28872
  kind: "query",
28775
28873
  auth: "protected"
28874
+ }), method(object({
28875
+ deviceIds: array(number()).min(1).max(200),
28876
+ fromMs: number(),
28877
+ toMs: number(),
28878
+ tzOffsetMinutes: number()
28879
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28880
+ kind: "query",
28881
+ auth: "protected"
28776
28882
  }), method(object({
28777
28883
  deviceId: number(),
28778
28884
  fromMs: number(),
@@ -33935,6 +34041,12 @@ Object.freeze({
33935
34041
  addonId: null,
33936
34042
  access: "view"
33937
34043
  },
34044
+ "deviceManager.migrateDevice": {
34045
+ capName: "device-manager",
34046
+ capScope: "system",
34047
+ addonId: null,
34048
+ access: "create"
34049
+ },
33938
34050
  "deviceManager.persistConfig": {
33939
34051
  capName: "device-manager",
33940
34052
  capScope: "system",
@@ -35669,6 +35781,12 @@ Object.freeze({
35669
35781
  addonId: null,
35670
35782
  access: "view"
35671
35783
  },
35784
+ "pipelineAnalytics.getEventDensityBatch": {
35785
+ capName: "pipeline-analytics",
35786
+ capScope: "device",
35787
+ addonId: null,
35788
+ access: "view"
35789
+ },
35672
35790
  "pipelineAnalytics.getEventMedia": {
35673
35791
  capName: "pipeline-analytics",
35674
35792
  capScope: "device",
@@ -36791,12 +36909,24 @@ Object.freeze({
36791
36909
  addonId: null,
36792
36910
  access: "view"
36793
36911
  },
36912
+ "recording.getAvailabilityBatch": {
36913
+ capName: "recording",
36914
+ capScope: "system",
36915
+ addonId: null,
36916
+ access: "view"
36917
+ },
36794
36918
  "recording.getDaysWithRecordings": {
36795
36919
  capName: "recording",
36796
36920
  capScope: "system",
36797
36921
  addonId: null,
36798
36922
  access: "view"
36799
36923
  },
36924
+ "recording.getDaysWithRecordingsBatch": {
36925
+ capName: "recording",
36926
+ capScope: "system",
36927
+ addonId: null,
36928
+ access: "view"
36929
+ },
36800
36930
  "recording.getDeviceConfig": {
36801
36931
  capName: "recording",
36802
36932
  capScope: "system",
@@ -39401,6 +39531,11 @@ Object.freeze({
39401
39531
  form: "single",
39402
39532
  optional: false
39403
39533
  }],
39534
+ "pipelineAnalytics.getEventDensityBatch": [{
39535
+ name: "deviceIds",
39536
+ form: "array",
39537
+ optional: false
39538
+ }],
39404
39539
  "pipelineAnalytics.getEventMedia": [{
39405
39540
  name: "deviceId",
39406
39541
  form: "single",
@@ -39816,11 +39951,21 @@ Object.freeze({
39816
39951
  form: "single",
39817
39952
  optional: false
39818
39953
  }],
39954
+ "recording.getAvailabilityBatch": [{
39955
+ name: "deviceIds",
39956
+ form: "array",
39957
+ optional: false
39958
+ }],
39819
39959
  "recording.getDaysWithRecordings": [{
39820
39960
  name: "deviceId",
39821
39961
  form: "single",
39822
39962
  optional: false
39823
39963
  }],
39964
+ "recording.getDaysWithRecordingsBatch": [{
39965
+ name: "deviceIds",
39966
+ form: "array",
39967
+ optional: false
39968
+ }],
39824
39969
  "recording.getDeviceConfig": [{
39825
39970
  name: "deviceId",
39826
39971
  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({
@@ -28530,6 +28588,39 @@ var RecordingDaysSchema = object({
28530
28588
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28531
28589
  days: array(number())
28532
28590
  });
28591
+ /**
28592
+ * One camera's row in a `getAvailabilityBatch` answer.
28593
+ *
28594
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28595
+ * batch collapses the transport, not the work — plus the one thing the singular
28596
+ * method never had to say:
28597
+ *
28598
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28599
+ * no footage in the window", which is a real claim.
28600
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28601
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28602
+ * that emptiness means NOTHING.
28603
+ *
28604
+ * A timeline that renders the second as the first tells an operator there is no
28605
+ * footage when the truth is that nobody looked.
28606
+ */
28607
+ var RecordingAvailabilityForDeviceSchema = object({
28608
+ deviceId: number(),
28609
+ read: _enum(["read", "unreadable"]),
28610
+ ranges: array(RecordingRangeSchema).readonly()
28611
+ });
28612
+ /**
28613
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28614
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28615
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28616
+ * the date-picker's day dots must not spell them the same.
28617
+ */
28618
+ var RecordingDaysForDeviceSchema = object({
28619
+ deviceId: number(),
28620
+ read: _enum(["read", "unreadable"]),
28621
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28622
+ days: array(number()).readonly()
28623
+ });
28533
28624
  var RecordingManifestSchema = object({
28534
28625
  deviceId: number(),
28535
28626
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28764,6 +28855,13 @@ method(object({
28764
28855
  }), RecordingAvailabilitySchema, {
28765
28856
  kind: "query",
28766
28857
  auth: "protected"
28858
+ }), method(object({
28859
+ deviceIds: array(number()).min(1).max(200),
28860
+ fromMs: number(),
28861
+ toMs: number()
28862
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28863
+ kind: "query",
28864
+ auth: "protected"
28767
28865
  }), method(object({
28768
28866
  deviceId: number(),
28769
28867
  fromMs: number(),
@@ -28772,6 +28870,14 @@ method(object({
28772
28870
  }), RecordingDaysSchema, {
28773
28871
  kind: "query",
28774
28872
  auth: "protected"
28873
+ }), method(object({
28874
+ deviceIds: array(number()).min(1).max(200),
28875
+ fromMs: number(),
28876
+ toMs: number(),
28877
+ tzOffsetMinutes: number()
28878
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28879
+ kind: "query",
28880
+ auth: "protected"
28775
28881
  }), method(object({
28776
28882
  deviceId: number(),
28777
28883
  fromMs: number(),
@@ -33934,6 +34040,12 @@ Object.freeze({
33934
34040
  addonId: null,
33935
34041
  access: "view"
33936
34042
  },
34043
+ "deviceManager.migrateDevice": {
34044
+ capName: "device-manager",
34045
+ capScope: "system",
34046
+ addonId: null,
34047
+ access: "create"
34048
+ },
33937
34049
  "deviceManager.persistConfig": {
33938
34050
  capName: "device-manager",
33939
34051
  capScope: "system",
@@ -35668,6 +35780,12 @@ Object.freeze({
35668
35780
  addonId: null,
35669
35781
  access: "view"
35670
35782
  },
35783
+ "pipelineAnalytics.getEventDensityBatch": {
35784
+ capName: "pipeline-analytics",
35785
+ capScope: "device",
35786
+ addonId: null,
35787
+ access: "view"
35788
+ },
35671
35789
  "pipelineAnalytics.getEventMedia": {
35672
35790
  capName: "pipeline-analytics",
35673
35791
  capScope: "device",
@@ -36790,12 +36908,24 @@ Object.freeze({
36790
36908
  addonId: null,
36791
36909
  access: "view"
36792
36910
  },
36911
+ "recording.getAvailabilityBatch": {
36912
+ capName: "recording",
36913
+ capScope: "system",
36914
+ addonId: null,
36915
+ access: "view"
36916
+ },
36793
36917
  "recording.getDaysWithRecordings": {
36794
36918
  capName: "recording",
36795
36919
  capScope: "system",
36796
36920
  addonId: null,
36797
36921
  access: "view"
36798
36922
  },
36923
+ "recording.getDaysWithRecordingsBatch": {
36924
+ capName: "recording",
36925
+ capScope: "system",
36926
+ addonId: null,
36927
+ access: "view"
36928
+ },
36799
36929
  "recording.getDeviceConfig": {
36800
36930
  capName: "recording",
36801
36931
  capScope: "system",
@@ -39400,6 +39530,11 @@ Object.freeze({
39400
39530
  form: "single",
39401
39531
  optional: false
39402
39532
  }],
39533
+ "pipelineAnalytics.getEventDensityBatch": [{
39534
+ name: "deviceIds",
39535
+ form: "array",
39536
+ optional: false
39537
+ }],
39403
39538
  "pipelineAnalytics.getEventMedia": [{
39404
39539
  name: "deviceId",
39405
39540
  form: "single",
@@ -39815,11 +39950,21 @@ Object.freeze({
39815
39950
  form: "single",
39816
39951
  optional: false
39817
39952
  }],
39953
+ "recording.getAvailabilityBatch": [{
39954
+ name: "deviceIds",
39955
+ form: "array",
39956
+ optional: false
39957
+ }],
39818
39958
  "recording.getDaysWithRecordings": [{
39819
39959
  name: "deviceId",
39820
39960
  form: "single",
39821
39961
  optional: false
39822
39962
  }],
39963
+ "recording.getDaysWithRecordingsBatch": [{
39964
+ name: "deviceIds",
39965
+ form: "array",
39966
+ optional: false
39967
+ }],
39823
39968
  "recording.getDeviceConfig": [{
39824
39969
  name: "deviceId",
39825
39970
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-velux",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Velux KLF-200 device-provider addon for CamStack — local-gateway window + shutter covers (positional, no tilt)",
5
5
  "keywords": [
6
6
  "camstack",