@camstack/addon-provider-tuya 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
@@ -8538,7 +8538,8 @@ var OpsLogReasonSchema = _enum([
8538
8538
  "quota",
8539
8539
  "manual",
8540
8540
  "operator",
8541
- "maintenance"
8541
+ "maintenance",
8542
+ "orphaned-device"
8542
8543
  ]);
8543
8544
  /** One audit row, shared verbatim by both domains. */
8544
8545
  var OpsLogEntrySchema = object({
@@ -13850,10 +13851,39 @@ var DevicePersistConfigPayloadSchema = object({
13850
13851
  deviceId: number(),
13851
13852
  data: record(string(), unknown())
13852
13853
  });
13854
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13855
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13856
+ var MigrateSwitchOutcomeSchema = _enum([
13857
+ "off",
13858
+ "on",
13859
+ "not-offered",
13860
+ "unreachable"
13861
+ ]);
13862
+ var MigrateSwitchReportSchema = object({
13863
+ deviceId: number(),
13864
+ switchId: CameraSwitchIdSchema,
13865
+ outcome: MigrateSwitchOutcomeSchema,
13866
+ detail: string().optional()
13867
+ });
13868
+ var MigrateDeviceResultSchema = object({
13869
+ sourceId: number(),
13870
+ targetId: number(),
13871
+ switches: array(MigrateSwitchReportSchema).readonly(),
13872
+ /** Switches that could NOT be turned off on the source. Empty is the only
13873
+ * value meaning the replaced hardware is quiet. */
13874
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13875
+ swapped: boolean()
13876
+ });
13853
13877
  method(object({
13854
13878
  addonId: string(),
13855
13879
  stableId: string()
13856
- }), 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({
13880
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13881
+ sourceId: number(),
13882
+ targetId: number()
13883
+ }), MigrateDeviceResultSchema, {
13884
+ kind: "mutation",
13885
+ auth: "admin"
13886
+ }), 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({
13857
13887
  deviceId: number(),
13858
13888
  name: string()
13859
13889
  }), _void(), {
@@ -20028,6 +20058,34 @@ var KeyEventsForDeviceSchema = object({
20028
20058
  deviceId: number(),
20029
20059
  events: array(KeyEventSchema).readonly()
20030
20060
  });
20061
+ /** One non-empty bucket of the timeline histogram. */
20062
+ var EventDensityBucketSchema = object({
20063
+ bucketStart: number(),
20064
+ motion: number().int(),
20065
+ object: number().int(),
20066
+ audio: number().int()
20067
+ });
20068
+ /**
20069
+ * One camera's row in a `getEventDensityBatch` answer.
20070
+ *
20071
+ * TWO facts, and the timeline draws them differently:
20072
+ *
20073
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
20074
+ * claim: read, and nothing happened in the window.
20075
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
20076
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
20077
+ * not quiet.
20078
+ *
20079
+ * Fanned out per camera the difference was free — one query rejected while the
20080
+ * others resolved — and collapsing to a batch is the exact moment it is lost
20081
+ * silently. Every requested id gets a row: a caller that asked for twelve and
20082
+ * got eleven cannot tell which one is missing, or that any is.
20083
+ */
20084
+ var EventDensityForDeviceSchema = object({
20085
+ deviceId: number(),
20086
+ read: _enum(["read", "unreadable"]),
20087
+ buckets: array(EventDensityBucketSchema).readonly()
20088
+ });
20031
20089
  object({
20032
20090
  trackId: string(),
20033
20091
  className: string(),
@@ -20338,12 +20396,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20338
20396
  since: number(),
20339
20397
  until: number(),
20340
20398
  bucketMs: number().int().positive()
20341
- }), array(object({
20342
- bucketStart: number(),
20343
- motion: number().int(),
20344
- object: number().int(),
20345
- audio: number().int()
20346
- })).readonly()), method(object({
20399
+ }), array(EventDensityBucketSchema).readonly()), method(object({
20400
+ deviceIds: array(number()).min(1).max(200),
20401
+ since: number(),
20402
+ until: number(),
20403
+ bucketMs: number().int().positive()
20404
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
20347
20405
  deviceId: number(),
20348
20406
  cutoffMs: number()
20349
20407
  }), object({
@@ -29376,6 +29434,39 @@ var RecordingDaysSchema = object({
29376
29434
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29377
29435
  days: array(number())
29378
29436
  });
29437
+ /**
29438
+ * One camera's row in a `getAvailabilityBatch` answer.
29439
+ *
29440
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
29441
+ * batch collapses the transport, not the work — plus the one thing the singular
29442
+ * method never had to say:
29443
+ *
29444
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
29445
+ * no footage in the window", which is a real claim.
29446
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
29447
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
29448
+ * that emptiness means NOTHING.
29449
+ *
29450
+ * A timeline that renders the second as the first tells an operator there is no
29451
+ * footage when the truth is that nobody looked.
29452
+ */
29453
+ var RecordingAvailabilityForDeviceSchema = object({
29454
+ deviceId: number(),
29455
+ read: _enum(["read", "unreadable"]),
29456
+ ranges: array(RecordingRangeSchema).readonly()
29457
+ });
29458
+ /**
29459
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
29460
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
29461
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
29462
+ * the date-picker's day dots must not spell them the same.
29463
+ */
29464
+ var RecordingDaysForDeviceSchema = object({
29465
+ deviceId: number(),
29466
+ read: _enum(["read", "unreadable"]),
29467
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29468
+ days: array(number()).readonly()
29469
+ });
29379
29470
  var RecordingManifestSchema = object({
29380
29471
  deviceId: number(),
29381
29472
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -29610,6 +29701,13 @@ method(object({
29610
29701
  }), RecordingAvailabilitySchema, {
29611
29702
  kind: "query",
29612
29703
  auth: "protected"
29704
+ }), method(object({
29705
+ deviceIds: array(number()).min(1).max(200),
29706
+ fromMs: number(),
29707
+ toMs: number()
29708
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
29709
+ kind: "query",
29710
+ auth: "protected"
29613
29711
  }), method(object({
29614
29712
  deviceId: number(),
29615
29713
  fromMs: number(),
@@ -29618,6 +29716,14 @@ method(object({
29618
29716
  }), RecordingDaysSchema, {
29619
29717
  kind: "query",
29620
29718
  auth: "protected"
29719
+ }), method(object({
29720
+ deviceIds: array(number()).min(1).max(200),
29721
+ fromMs: number(),
29722
+ toMs: number(),
29723
+ tzOffsetMinutes: number()
29724
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
29725
+ kind: "query",
29726
+ auth: "protected"
29621
29727
  }), method(object({
29622
29728
  deviceId: number(),
29623
29729
  fromMs: number(),
@@ -34780,6 +34886,12 @@ Object.freeze({
34780
34886
  addonId: null,
34781
34887
  access: "view"
34782
34888
  },
34889
+ "deviceManager.migrateDevice": {
34890
+ capName: "device-manager",
34891
+ capScope: "system",
34892
+ addonId: null,
34893
+ access: "create"
34894
+ },
34783
34895
  "deviceManager.persistConfig": {
34784
34896
  capName: "device-manager",
34785
34897
  capScope: "system",
@@ -36514,6 +36626,12 @@ Object.freeze({
36514
36626
  addonId: null,
36515
36627
  access: "view"
36516
36628
  },
36629
+ "pipelineAnalytics.getEventDensityBatch": {
36630
+ capName: "pipeline-analytics",
36631
+ capScope: "device",
36632
+ addonId: null,
36633
+ access: "view"
36634
+ },
36517
36635
  "pipelineAnalytics.getEventMedia": {
36518
36636
  capName: "pipeline-analytics",
36519
36637
  capScope: "device",
@@ -37636,12 +37754,24 @@ Object.freeze({
37636
37754
  addonId: null,
37637
37755
  access: "view"
37638
37756
  },
37757
+ "recording.getAvailabilityBatch": {
37758
+ capName: "recording",
37759
+ capScope: "system",
37760
+ addonId: null,
37761
+ access: "view"
37762
+ },
37639
37763
  "recording.getDaysWithRecordings": {
37640
37764
  capName: "recording",
37641
37765
  capScope: "system",
37642
37766
  addonId: null,
37643
37767
  access: "view"
37644
37768
  },
37769
+ "recording.getDaysWithRecordingsBatch": {
37770
+ capName: "recording",
37771
+ capScope: "system",
37772
+ addonId: null,
37773
+ access: "view"
37774
+ },
37645
37775
  "recording.getDeviceConfig": {
37646
37776
  capName: "recording",
37647
37777
  capScope: "system",
@@ -40246,6 +40376,11 @@ Object.freeze({
40246
40376
  form: "single",
40247
40377
  optional: false
40248
40378
  }],
40379
+ "pipelineAnalytics.getEventDensityBatch": [{
40380
+ name: "deviceIds",
40381
+ form: "array",
40382
+ optional: false
40383
+ }],
40249
40384
  "pipelineAnalytics.getEventMedia": [{
40250
40385
  name: "deviceId",
40251
40386
  form: "single",
@@ -40661,11 +40796,21 @@ Object.freeze({
40661
40796
  form: "single",
40662
40797
  optional: false
40663
40798
  }],
40799
+ "recording.getAvailabilityBatch": [{
40800
+ name: "deviceIds",
40801
+ form: "array",
40802
+ optional: false
40803
+ }],
40664
40804
  "recording.getDaysWithRecordings": [{
40665
40805
  name: "deviceId",
40666
40806
  form: "single",
40667
40807
  optional: false
40668
40808
  }],
40809
+ "recording.getDaysWithRecordingsBatch": [{
40810
+ name: "deviceIds",
40811
+ form: "array",
40812
+ optional: false
40813
+ }],
40669
40814
  "recording.getDeviceConfig": [{
40670
40815
  name: "deviceId",
40671
40816
  form: "single",
package/dist/addon.mjs CHANGED
@@ -8537,7 +8537,8 @@ var OpsLogReasonSchema = _enum([
8537
8537
  "quota",
8538
8538
  "manual",
8539
8539
  "operator",
8540
- "maintenance"
8540
+ "maintenance",
8541
+ "orphaned-device"
8541
8542
  ]);
8542
8543
  /** One audit row, shared verbatim by both domains. */
8543
8544
  var OpsLogEntrySchema = object({
@@ -13849,10 +13850,39 @@ var DevicePersistConfigPayloadSchema = object({
13849
13850
  deviceId: number(),
13850
13851
  data: record(string(), unknown())
13851
13852
  });
13853
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13854
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13855
+ var MigrateSwitchOutcomeSchema = _enum([
13856
+ "off",
13857
+ "on",
13858
+ "not-offered",
13859
+ "unreachable"
13860
+ ]);
13861
+ var MigrateSwitchReportSchema = object({
13862
+ deviceId: number(),
13863
+ switchId: CameraSwitchIdSchema,
13864
+ outcome: MigrateSwitchOutcomeSchema,
13865
+ detail: string().optional()
13866
+ });
13867
+ var MigrateDeviceResultSchema = object({
13868
+ sourceId: number(),
13869
+ targetId: number(),
13870
+ switches: array(MigrateSwitchReportSchema).readonly(),
13871
+ /** Switches that could NOT be turned off on the source. Empty is the only
13872
+ * value meaning the replaced hardware is quiet. */
13873
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13874
+ swapped: boolean()
13875
+ });
13852
13876
  method(object({
13853
13877
  addonId: string(),
13854
13878
  stableId: string()
13855
- }), 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({
13879
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13880
+ sourceId: number(),
13881
+ targetId: number()
13882
+ }), MigrateDeviceResultSchema, {
13883
+ kind: "mutation",
13884
+ auth: "admin"
13885
+ }), 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({
13856
13886
  deviceId: number(),
13857
13887
  name: string()
13858
13888
  }), _void(), {
@@ -20027,6 +20057,34 @@ var KeyEventsForDeviceSchema = object({
20027
20057
  deviceId: number(),
20028
20058
  events: array(KeyEventSchema).readonly()
20029
20059
  });
20060
+ /** One non-empty bucket of the timeline histogram. */
20061
+ var EventDensityBucketSchema = object({
20062
+ bucketStart: number(),
20063
+ motion: number().int(),
20064
+ object: number().int(),
20065
+ audio: number().int()
20066
+ });
20067
+ /**
20068
+ * One camera's row in a `getEventDensityBatch` answer.
20069
+ *
20070
+ * TWO facts, and the timeline draws them differently:
20071
+ *
20072
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
20073
+ * claim: read, and nothing happened in the window.
20074
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
20075
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
20076
+ * not quiet.
20077
+ *
20078
+ * Fanned out per camera the difference was free — one query rejected while the
20079
+ * others resolved — and collapsing to a batch is the exact moment it is lost
20080
+ * silently. Every requested id gets a row: a caller that asked for twelve and
20081
+ * got eleven cannot tell which one is missing, or that any is.
20082
+ */
20083
+ var EventDensityForDeviceSchema = object({
20084
+ deviceId: number(),
20085
+ read: _enum(["read", "unreadable"]),
20086
+ buckets: array(EventDensityBucketSchema).readonly()
20087
+ });
20030
20088
  object({
20031
20089
  trackId: string(),
20032
20090
  className: string(),
@@ -20337,12 +20395,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20337
20395
  since: number(),
20338
20396
  until: number(),
20339
20397
  bucketMs: number().int().positive()
20340
- }), array(object({
20341
- bucketStart: number(),
20342
- motion: number().int(),
20343
- object: number().int(),
20344
- audio: number().int()
20345
- })).readonly()), method(object({
20398
+ }), array(EventDensityBucketSchema).readonly()), method(object({
20399
+ deviceIds: array(number()).min(1).max(200),
20400
+ since: number(),
20401
+ until: number(),
20402
+ bucketMs: number().int().positive()
20403
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
20346
20404
  deviceId: number(),
20347
20405
  cutoffMs: number()
20348
20406
  }), object({
@@ -29375,6 +29433,39 @@ var RecordingDaysSchema = object({
29375
29433
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29376
29434
  days: array(number())
29377
29435
  });
29436
+ /**
29437
+ * One camera's row in a `getAvailabilityBatch` answer.
29438
+ *
29439
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
29440
+ * batch collapses the transport, not the work — plus the one thing the singular
29441
+ * method never had to say:
29442
+ *
29443
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
29444
+ * no footage in the window", which is a real claim.
29445
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
29446
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
29447
+ * that emptiness means NOTHING.
29448
+ *
29449
+ * A timeline that renders the second as the first tells an operator there is no
29450
+ * footage when the truth is that nobody looked.
29451
+ */
29452
+ var RecordingAvailabilityForDeviceSchema = object({
29453
+ deviceId: number(),
29454
+ read: _enum(["read", "unreadable"]),
29455
+ ranges: array(RecordingRangeSchema).readonly()
29456
+ });
29457
+ /**
29458
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
29459
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
29460
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
29461
+ * the date-picker's day dots must not spell them the same.
29462
+ */
29463
+ var RecordingDaysForDeviceSchema = object({
29464
+ deviceId: number(),
29465
+ read: _enum(["read", "unreadable"]),
29466
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29467
+ days: array(number()).readonly()
29468
+ });
29378
29469
  var RecordingManifestSchema = object({
29379
29470
  deviceId: number(),
29380
29471
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -29609,6 +29700,13 @@ method(object({
29609
29700
  }), RecordingAvailabilitySchema, {
29610
29701
  kind: "query",
29611
29702
  auth: "protected"
29703
+ }), method(object({
29704
+ deviceIds: array(number()).min(1).max(200),
29705
+ fromMs: number(),
29706
+ toMs: number()
29707
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
29708
+ kind: "query",
29709
+ auth: "protected"
29612
29710
  }), method(object({
29613
29711
  deviceId: number(),
29614
29712
  fromMs: number(),
@@ -29617,6 +29715,14 @@ method(object({
29617
29715
  }), RecordingDaysSchema, {
29618
29716
  kind: "query",
29619
29717
  auth: "protected"
29718
+ }), method(object({
29719
+ deviceIds: array(number()).min(1).max(200),
29720
+ fromMs: number(),
29721
+ toMs: number(),
29722
+ tzOffsetMinutes: number()
29723
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
29724
+ kind: "query",
29725
+ auth: "protected"
29620
29726
  }), method(object({
29621
29727
  deviceId: number(),
29622
29728
  fromMs: number(),
@@ -34779,6 +34885,12 @@ Object.freeze({
34779
34885
  addonId: null,
34780
34886
  access: "view"
34781
34887
  },
34888
+ "deviceManager.migrateDevice": {
34889
+ capName: "device-manager",
34890
+ capScope: "system",
34891
+ addonId: null,
34892
+ access: "create"
34893
+ },
34782
34894
  "deviceManager.persistConfig": {
34783
34895
  capName: "device-manager",
34784
34896
  capScope: "system",
@@ -36513,6 +36625,12 @@ Object.freeze({
36513
36625
  addonId: null,
36514
36626
  access: "view"
36515
36627
  },
36628
+ "pipelineAnalytics.getEventDensityBatch": {
36629
+ capName: "pipeline-analytics",
36630
+ capScope: "device",
36631
+ addonId: null,
36632
+ access: "view"
36633
+ },
36516
36634
  "pipelineAnalytics.getEventMedia": {
36517
36635
  capName: "pipeline-analytics",
36518
36636
  capScope: "device",
@@ -37635,12 +37753,24 @@ Object.freeze({
37635
37753
  addonId: null,
37636
37754
  access: "view"
37637
37755
  },
37756
+ "recording.getAvailabilityBatch": {
37757
+ capName: "recording",
37758
+ capScope: "system",
37759
+ addonId: null,
37760
+ access: "view"
37761
+ },
37638
37762
  "recording.getDaysWithRecordings": {
37639
37763
  capName: "recording",
37640
37764
  capScope: "system",
37641
37765
  addonId: null,
37642
37766
  access: "view"
37643
37767
  },
37768
+ "recording.getDaysWithRecordingsBatch": {
37769
+ capName: "recording",
37770
+ capScope: "system",
37771
+ addonId: null,
37772
+ access: "view"
37773
+ },
37644
37774
  "recording.getDeviceConfig": {
37645
37775
  capName: "recording",
37646
37776
  capScope: "system",
@@ -40245,6 +40375,11 @@ Object.freeze({
40245
40375
  form: "single",
40246
40376
  optional: false
40247
40377
  }],
40378
+ "pipelineAnalytics.getEventDensityBatch": [{
40379
+ name: "deviceIds",
40380
+ form: "array",
40381
+ optional: false
40382
+ }],
40248
40383
  "pipelineAnalytics.getEventMedia": [{
40249
40384
  name: "deviceId",
40250
40385
  form: "single",
@@ -40660,11 +40795,21 @@ Object.freeze({
40660
40795
  form: "single",
40661
40796
  optional: false
40662
40797
  }],
40798
+ "recording.getAvailabilityBatch": [{
40799
+ name: "deviceIds",
40800
+ form: "array",
40801
+ optional: false
40802
+ }],
40663
40803
  "recording.getDaysWithRecordings": [{
40664
40804
  name: "deviceId",
40665
40805
  form: "single",
40666
40806
  optional: false
40667
40807
  }],
40808
+ "recording.getDaysWithRecordingsBatch": [{
40809
+ name: "deviceIds",
40810
+ form: "array",
40811
+ optional: false
40812
+ }],
40668
40813
  "recording.getDeviceConfig": [{
40669
40814
  name: "deviceId",
40670
40815
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-tuya",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Tuya / Smart Life device-provider addon for CamStack — account-onboarded (Tuya IoT cloud fetch of device localKeys) + LOCAL DP control via the @apocaliss92/nodetuya encrypted-LAN client, exposing switch / water-heater-family kettle entities",
5
5
  "keywords": [
6
6
  "camstack",