@camstack/addon-export-google 0.1.15 → 0.1.17

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.
@@ -7911,7 +7911,8 @@ var OpsLogReasonSchema = _enum([
7911
7911
  "quota",
7912
7912
  "manual",
7913
7913
  "operator",
7914
- "maintenance"
7914
+ "maintenance",
7915
+ "orphaned-device"
7915
7916
  ]);
7916
7917
  /** One audit row, shared verbatim by both domains. */
7917
7918
  var OpsLogEntrySchema = object({
@@ -13015,10 +13016,39 @@ var DevicePersistConfigPayloadSchema = object({
13015
13016
  deviceId: number(),
13016
13017
  data: record(string(), unknown())
13017
13018
  });
13019
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13020
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13021
+ var MigrateSwitchOutcomeSchema = _enum([
13022
+ "off",
13023
+ "on",
13024
+ "not-offered",
13025
+ "unreachable"
13026
+ ]);
13027
+ var MigrateSwitchReportSchema = object({
13028
+ deviceId: number(),
13029
+ switchId: CameraSwitchIdSchema,
13030
+ outcome: MigrateSwitchOutcomeSchema,
13031
+ detail: string().optional()
13032
+ });
13033
+ var MigrateDeviceResultSchema = object({
13034
+ sourceId: number(),
13035
+ targetId: number(),
13036
+ switches: array(MigrateSwitchReportSchema).readonly(),
13037
+ /** Switches that could NOT be turned off on the source. Empty is the only
13038
+ * value meaning the replaced hardware is quiet. */
13039
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13040
+ swapped: boolean()
13041
+ });
13018
13042
  method(object({
13019
13043
  addonId: string(),
13020
13044
  stableId: string()
13021
- }), 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({
13045
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13046
+ sourceId: number(),
13047
+ targetId: number()
13048
+ }), MigrateDeviceResultSchema, {
13049
+ kind: "mutation",
13050
+ auth: "admin"
13051
+ }), 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({
13022
13052
  deviceId: number(),
13023
13053
  name: string()
13024
13054
  }), _void(), {
@@ -19131,6 +19161,34 @@ var KeyEventsForDeviceSchema = object({
19131
19161
  deviceId: number(),
19132
19162
  events: array(KeyEventSchema).readonly()
19133
19163
  });
19164
+ /** One non-empty bucket of the timeline histogram. */
19165
+ var EventDensityBucketSchema = object({
19166
+ bucketStart: number(),
19167
+ motion: number().int(),
19168
+ object: number().int(),
19169
+ audio: number().int()
19170
+ });
19171
+ /**
19172
+ * One camera's row in a `getEventDensityBatch` answer.
19173
+ *
19174
+ * TWO facts, and the timeline draws them differently:
19175
+ *
19176
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19177
+ * claim: read, and nothing happened in the window.
19178
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19179
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19180
+ * not quiet.
19181
+ *
19182
+ * Fanned out per camera the difference was free — one query rejected while the
19183
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19184
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19185
+ * got eleven cannot tell which one is missing, or that any is.
19186
+ */
19187
+ var EventDensityForDeviceSchema = object({
19188
+ deviceId: number(),
19189
+ read: _enum(["read", "unreadable"]),
19190
+ buckets: array(EventDensityBucketSchema).readonly()
19191
+ });
19134
19192
  object({
19135
19193
  trackId: string(),
19136
19194
  className: string(),
@@ -19441,12 +19499,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19441
19499
  since: number(),
19442
19500
  until: number(),
19443
19501
  bucketMs: number().int().positive()
19444
- }), array(object({
19445
- bucketStart: number(),
19446
- motion: number().int(),
19447
- object: number().int(),
19448
- audio: number().int()
19449
- })).readonly()), method(object({
19502
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19503
+ deviceIds: array(number()).min(1).max(200),
19504
+ since: number(),
19505
+ until: number(),
19506
+ bucketMs: number().int().positive()
19507
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19450
19508
  deviceId: number(),
19451
19509
  cutoffMs: number()
19452
19510
  }), object({
@@ -26768,6 +26826,39 @@ var RecordingDaysSchema = object({
26768
26826
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
26769
26827
  days: array(number())
26770
26828
  });
26829
+ /**
26830
+ * One camera's row in a `getAvailabilityBatch` answer.
26831
+ *
26832
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
26833
+ * batch collapses the transport, not the work — plus the one thing the singular
26834
+ * method never had to say:
26835
+ *
26836
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
26837
+ * no footage in the window", which is a real claim.
26838
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
26839
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
26840
+ * that emptiness means NOTHING.
26841
+ *
26842
+ * A timeline that renders the second as the first tells an operator there is no
26843
+ * footage when the truth is that nobody looked.
26844
+ */
26845
+ var RecordingAvailabilityForDeviceSchema = object({
26846
+ deviceId: number(),
26847
+ read: _enum(["read", "unreadable"]),
26848
+ ranges: array(RecordingRangeSchema).readonly()
26849
+ });
26850
+ /**
26851
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
26852
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
26853
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
26854
+ * the date-picker's day dots must not spell them the same.
26855
+ */
26856
+ var RecordingDaysForDeviceSchema = object({
26857
+ deviceId: number(),
26858
+ read: _enum(["read", "unreadable"]),
26859
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
26860
+ days: array(number()).readonly()
26861
+ });
26771
26862
  var RecordingManifestSchema = object({
26772
26863
  deviceId: number(),
26773
26864
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -27002,6 +27093,13 @@ method(object({
27002
27093
  }), RecordingAvailabilitySchema, {
27003
27094
  kind: "query",
27004
27095
  auth: "protected"
27096
+ }), method(object({
27097
+ deviceIds: array(number()).min(1).max(200),
27098
+ fromMs: number(),
27099
+ toMs: number()
27100
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
27101
+ kind: "query",
27102
+ auth: "protected"
27005
27103
  }), method(object({
27006
27104
  deviceId: number(),
27007
27105
  fromMs: number(),
@@ -27010,6 +27108,14 @@ method(object({
27010
27108
  }), RecordingDaysSchema, {
27011
27109
  kind: "query",
27012
27110
  auth: "protected"
27111
+ }), method(object({
27112
+ deviceIds: array(number()).min(1).max(200),
27113
+ fromMs: number(),
27114
+ toMs: number(),
27115
+ tzOffsetMinutes: number()
27116
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
27117
+ kind: "query",
27118
+ auth: "protected"
27013
27119
  }), method(object({
27014
27120
  deviceId: number(),
27015
27121
  fromMs: number(),
@@ -30480,6 +30586,12 @@ Object.freeze({
30480
30586
  addonId: null,
30481
30587
  access: "view"
30482
30588
  },
30589
+ "deviceManager.migrateDevice": {
30590
+ capName: "device-manager",
30591
+ capScope: "system",
30592
+ addonId: null,
30593
+ access: "create"
30594
+ },
30483
30595
  "deviceManager.persistConfig": {
30484
30596
  capName: "device-manager",
30485
30597
  capScope: "system",
@@ -32214,6 +32326,12 @@ Object.freeze({
32214
32326
  addonId: null,
32215
32327
  access: "view"
32216
32328
  },
32329
+ "pipelineAnalytics.getEventDensityBatch": {
32330
+ capName: "pipeline-analytics",
32331
+ capScope: "device",
32332
+ addonId: null,
32333
+ access: "view"
32334
+ },
32217
32335
  "pipelineAnalytics.getEventMedia": {
32218
32336
  capName: "pipeline-analytics",
32219
32337
  capScope: "device",
@@ -33336,12 +33454,24 @@ Object.freeze({
33336
33454
  addonId: null,
33337
33455
  access: "view"
33338
33456
  },
33457
+ "recording.getAvailabilityBatch": {
33458
+ capName: "recording",
33459
+ capScope: "system",
33460
+ addonId: null,
33461
+ access: "view"
33462
+ },
33339
33463
  "recording.getDaysWithRecordings": {
33340
33464
  capName: "recording",
33341
33465
  capScope: "system",
33342
33466
  addonId: null,
33343
33467
  access: "view"
33344
33468
  },
33469
+ "recording.getDaysWithRecordingsBatch": {
33470
+ capName: "recording",
33471
+ capScope: "system",
33472
+ addonId: null,
33473
+ access: "view"
33474
+ },
33345
33475
  "recording.getDeviceConfig": {
33346
33476
  capName: "recording",
33347
33477
  capScope: "system",
@@ -35946,6 +36076,11 @@ Object.freeze({
35946
36076
  form: "single",
35947
36077
  optional: false
35948
36078
  }],
36079
+ "pipelineAnalytics.getEventDensityBatch": [{
36080
+ name: "deviceIds",
36081
+ form: "array",
36082
+ optional: false
36083
+ }],
35949
36084
  "pipelineAnalytics.getEventMedia": [{
35950
36085
  name: "deviceId",
35951
36086
  form: "single",
@@ -36361,11 +36496,21 @@ Object.freeze({
36361
36496
  form: "single",
36362
36497
  optional: false
36363
36498
  }],
36499
+ "recording.getAvailabilityBatch": [{
36500
+ name: "deviceIds",
36501
+ form: "array",
36502
+ optional: false
36503
+ }],
36364
36504
  "recording.getDaysWithRecordings": [{
36365
36505
  name: "deviceId",
36366
36506
  form: "single",
36367
36507
  optional: false
36368
36508
  }],
36509
+ "recording.getDaysWithRecordingsBatch": [{
36510
+ name: "deviceIds",
36511
+ form: "array",
36512
+ optional: false
36513
+ }],
36369
36514
  "recording.getDeviceConfig": [{
36370
36515
  name: "deviceId",
36371
36516
  form: "single",
@@ -7907,7 +7907,8 @@ var OpsLogReasonSchema = _enum([
7907
7907
  "quota",
7908
7908
  "manual",
7909
7909
  "operator",
7910
- "maintenance"
7910
+ "maintenance",
7911
+ "orphaned-device"
7911
7912
  ]);
7912
7913
  /** One audit row, shared verbatim by both domains. */
7913
7914
  var OpsLogEntrySchema = object({
@@ -13011,10 +13012,39 @@ var DevicePersistConfigPayloadSchema = object({
13011
13012
  deviceId: number(),
13012
13013
  data: record(string(), unknown())
13013
13014
  });
13015
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13016
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13017
+ var MigrateSwitchOutcomeSchema = _enum([
13018
+ "off",
13019
+ "on",
13020
+ "not-offered",
13021
+ "unreachable"
13022
+ ]);
13023
+ var MigrateSwitchReportSchema = object({
13024
+ deviceId: number(),
13025
+ switchId: CameraSwitchIdSchema,
13026
+ outcome: MigrateSwitchOutcomeSchema,
13027
+ detail: string().optional()
13028
+ });
13029
+ var MigrateDeviceResultSchema = object({
13030
+ sourceId: number(),
13031
+ targetId: number(),
13032
+ switches: array(MigrateSwitchReportSchema).readonly(),
13033
+ /** Switches that could NOT be turned off on the source. Empty is the only
13034
+ * value meaning the replaced hardware is quiet. */
13035
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13036
+ swapped: boolean()
13037
+ });
13014
13038
  method(object({
13015
13039
  addonId: string(),
13016
13040
  stableId: string()
13017
- }), 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({
13041
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13042
+ sourceId: number(),
13043
+ targetId: number()
13044
+ }), MigrateDeviceResultSchema, {
13045
+ kind: "mutation",
13046
+ auth: "admin"
13047
+ }), 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({
13018
13048
  deviceId: number(),
13019
13049
  name: string()
13020
13050
  }), _void(), {
@@ -19127,6 +19157,34 @@ var KeyEventsForDeviceSchema = object({
19127
19157
  deviceId: number(),
19128
19158
  events: array(KeyEventSchema).readonly()
19129
19159
  });
19160
+ /** One non-empty bucket of the timeline histogram. */
19161
+ var EventDensityBucketSchema = object({
19162
+ bucketStart: number(),
19163
+ motion: number().int(),
19164
+ object: number().int(),
19165
+ audio: number().int()
19166
+ });
19167
+ /**
19168
+ * One camera's row in a `getEventDensityBatch` answer.
19169
+ *
19170
+ * TWO facts, and the timeline draws them differently:
19171
+ *
19172
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19173
+ * claim: read, and nothing happened in the window.
19174
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19175
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19176
+ * not quiet.
19177
+ *
19178
+ * Fanned out per camera the difference was free — one query rejected while the
19179
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19180
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19181
+ * got eleven cannot tell which one is missing, or that any is.
19182
+ */
19183
+ var EventDensityForDeviceSchema = object({
19184
+ deviceId: number(),
19185
+ read: _enum(["read", "unreadable"]),
19186
+ buckets: array(EventDensityBucketSchema).readonly()
19187
+ });
19130
19188
  object({
19131
19189
  trackId: string(),
19132
19190
  className: string(),
@@ -19437,12 +19495,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19437
19495
  since: number(),
19438
19496
  until: number(),
19439
19497
  bucketMs: number().int().positive()
19440
- }), array(object({
19441
- bucketStart: number(),
19442
- motion: number().int(),
19443
- object: number().int(),
19444
- audio: number().int()
19445
- })).readonly()), method(object({
19498
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19499
+ deviceIds: array(number()).min(1).max(200),
19500
+ since: number(),
19501
+ until: number(),
19502
+ bucketMs: number().int().positive()
19503
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19446
19504
  deviceId: number(),
19447
19505
  cutoffMs: number()
19448
19506
  }), object({
@@ -26764,6 +26822,39 @@ var RecordingDaysSchema = object({
26764
26822
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
26765
26823
  days: array(number())
26766
26824
  });
26825
+ /**
26826
+ * One camera's row in a `getAvailabilityBatch` answer.
26827
+ *
26828
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
26829
+ * batch collapses the transport, not the work — plus the one thing the singular
26830
+ * method never had to say:
26831
+ *
26832
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
26833
+ * no footage in the window", which is a real claim.
26834
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
26835
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
26836
+ * that emptiness means NOTHING.
26837
+ *
26838
+ * A timeline that renders the second as the first tells an operator there is no
26839
+ * footage when the truth is that nobody looked.
26840
+ */
26841
+ var RecordingAvailabilityForDeviceSchema = object({
26842
+ deviceId: number(),
26843
+ read: _enum(["read", "unreadable"]),
26844
+ ranges: array(RecordingRangeSchema).readonly()
26845
+ });
26846
+ /**
26847
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
26848
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
26849
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
26850
+ * the date-picker's day dots must not spell them the same.
26851
+ */
26852
+ var RecordingDaysForDeviceSchema = object({
26853
+ deviceId: number(),
26854
+ read: _enum(["read", "unreadable"]),
26855
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
26856
+ days: array(number()).readonly()
26857
+ });
26767
26858
  var RecordingManifestSchema = object({
26768
26859
  deviceId: number(),
26769
26860
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -26998,6 +27089,13 @@ method(object({
26998
27089
  }), RecordingAvailabilitySchema, {
26999
27090
  kind: "query",
27000
27091
  auth: "protected"
27092
+ }), method(object({
27093
+ deviceIds: array(number()).min(1).max(200),
27094
+ fromMs: number(),
27095
+ toMs: number()
27096
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
27097
+ kind: "query",
27098
+ auth: "protected"
27001
27099
  }), method(object({
27002
27100
  deviceId: number(),
27003
27101
  fromMs: number(),
@@ -27006,6 +27104,14 @@ method(object({
27006
27104
  }), RecordingDaysSchema, {
27007
27105
  kind: "query",
27008
27106
  auth: "protected"
27107
+ }), method(object({
27108
+ deviceIds: array(number()).min(1).max(200),
27109
+ fromMs: number(),
27110
+ toMs: number(),
27111
+ tzOffsetMinutes: number()
27112
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
27113
+ kind: "query",
27114
+ auth: "protected"
27009
27115
  }), method(object({
27010
27116
  deviceId: number(),
27011
27117
  fromMs: number(),
@@ -30476,6 +30582,12 @@ Object.freeze({
30476
30582
  addonId: null,
30477
30583
  access: "view"
30478
30584
  },
30585
+ "deviceManager.migrateDevice": {
30586
+ capName: "device-manager",
30587
+ capScope: "system",
30588
+ addonId: null,
30589
+ access: "create"
30590
+ },
30479
30591
  "deviceManager.persistConfig": {
30480
30592
  capName: "device-manager",
30481
30593
  capScope: "system",
@@ -32210,6 +32322,12 @@ Object.freeze({
32210
32322
  addonId: null,
32211
32323
  access: "view"
32212
32324
  },
32325
+ "pipelineAnalytics.getEventDensityBatch": {
32326
+ capName: "pipeline-analytics",
32327
+ capScope: "device",
32328
+ addonId: null,
32329
+ access: "view"
32330
+ },
32213
32331
  "pipelineAnalytics.getEventMedia": {
32214
32332
  capName: "pipeline-analytics",
32215
32333
  capScope: "device",
@@ -33332,12 +33450,24 @@ Object.freeze({
33332
33450
  addonId: null,
33333
33451
  access: "view"
33334
33452
  },
33453
+ "recording.getAvailabilityBatch": {
33454
+ capName: "recording",
33455
+ capScope: "system",
33456
+ addonId: null,
33457
+ access: "view"
33458
+ },
33335
33459
  "recording.getDaysWithRecordings": {
33336
33460
  capName: "recording",
33337
33461
  capScope: "system",
33338
33462
  addonId: null,
33339
33463
  access: "view"
33340
33464
  },
33465
+ "recording.getDaysWithRecordingsBatch": {
33466
+ capName: "recording",
33467
+ capScope: "system",
33468
+ addonId: null,
33469
+ access: "view"
33470
+ },
33341
33471
  "recording.getDeviceConfig": {
33342
33472
  capName: "recording",
33343
33473
  capScope: "system",
@@ -35942,6 +36072,11 @@ Object.freeze({
35942
36072
  form: "single",
35943
36073
  optional: false
35944
36074
  }],
36075
+ "pipelineAnalytics.getEventDensityBatch": [{
36076
+ name: "deviceIds",
36077
+ form: "array",
36078
+ optional: false
36079
+ }],
35945
36080
  "pipelineAnalytics.getEventMedia": [{
35946
36081
  name: "deviceId",
35947
36082
  form: "single",
@@ -36357,11 +36492,21 @@ Object.freeze({
36357
36492
  form: "single",
36358
36493
  optional: false
36359
36494
  }],
36495
+ "recording.getAvailabilityBatch": [{
36496
+ name: "deviceIds",
36497
+ form: "array",
36498
+ optional: false
36499
+ }],
36360
36500
  "recording.getDaysWithRecordings": [{
36361
36501
  name: "deviceId",
36362
36502
  form: "single",
36363
36503
  optional: false
36364
36504
  }],
36505
+ "recording.getDaysWithRecordingsBatch": [{
36506
+ name: "deviceIds",
36507
+ form: "array",
36508
+ optional: false
36509
+ }],
36365
36510
  "recording.getDeviceConfig": [{
36366
36511
  name: "deviceId",
36367
36512
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-google",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Google Home export — hub-side smart-home fulfillment (SYNC / QUERY / EXECUTE / DISCONNECT) for the non-camera fleet, served over the hub's own OAuth account link. No Google credential is stored, sent or required.",
5
5
  "keywords": [
6
6
  "camstack",