@camstack/addon-provider-gree 0.2.50 → 0.2.52

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
@@ -7721,7 +7721,8 @@ var OpsLogReasonSchema = _enum([
7721
7721
  "quota",
7722
7722
  "manual",
7723
7723
  "operator",
7724
- "maintenance"
7724
+ "maintenance",
7725
+ "orphaned-device"
7725
7726
  ]);
7726
7727
  /** One audit row, shared verbatim by both domains. */
7727
7728
  var OpsLogEntrySchema = object({
@@ -13016,10 +13017,39 @@ var DevicePersistConfigPayloadSchema = object({
13016
13017
  deviceId: number(),
13017
13018
  data: record(string(), unknown())
13018
13019
  });
13020
+ /** What a migration actually did, per switch. `unreachable` is a first-class
13021
+ * answer: a camera that could not be asked is not a camera that was silenced. */
13022
+ var MigrateSwitchOutcomeSchema = _enum([
13023
+ "off",
13024
+ "on",
13025
+ "not-offered",
13026
+ "unreachable"
13027
+ ]);
13028
+ var MigrateSwitchReportSchema = object({
13029
+ deviceId: number(),
13030
+ switchId: CameraSwitchIdSchema,
13031
+ outcome: MigrateSwitchOutcomeSchema,
13032
+ detail: string().optional()
13033
+ });
13034
+ var MigrateDeviceResultSchema = object({
13035
+ sourceId: number(),
13036
+ targetId: number(),
13037
+ switches: array(MigrateSwitchReportSchema).readonly(),
13038
+ /** Switches that could NOT be turned off on the source. Empty is the only
13039
+ * value meaning the replaced hardware is quiet. */
13040
+ sourceStillLive: array(CameraSwitchIdSchema).readonly(),
13041
+ swapped: boolean()
13042
+ });
13019
13043
  method(object({
13020
13044
  addonId: string(),
13021
13045
  stableId: string()
13022
- }), 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({
13046
+ }), object({ id: number() }), { kind: "mutation" }), method(object({
13047
+ sourceId: number(),
13048
+ targetId: number()
13049
+ }), MigrateDeviceResultSchema, {
13050
+ kind: "mutation",
13051
+ auth: "admin"
13052
+ }), 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({
13023
13053
  deviceId: number(),
13024
13054
  name: string()
13025
13055
  }), _void(), {
@@ -19194,6 +19224,34 @@ var KeyEventsForDeviceSchema = object({
19194
19224
  deviceId: number(),
19195
19225
  events: array(KeyEventSchema).readonly()
19196
19226
  });
19227
+ /** One non-empty bucket of the timeline histogram. */
19228
+ var EventDensityBucketSchema = object({
19229
+ bucketStart: number(),
19230
+ motion: number().int(),
19231
+ object: number().int(),
19232
+ audio: number().int()
19233
+ });
19234
+ /**
19235
+ * One camera's row in a `getEventDensityBatch` answer.
19236
+ *
19237
+ * TWO facts, and the timeline draws them differently:
19238
+ *
19239
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19240
+ * claim: read, and nothing happened in the window.
19241
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19242
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19243
+ * not quiet.
19244
+ *
19245
+ * Fanned out per camera the difference was free — one query rejected while the
19246
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19247
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19248
+ * got eleven cannot tell which one is missing, or that any is.
19249
+ */
19250
+ var EventDensityForDeviceSchema = object({
19251
+ deviceId: number(),
19252
+ read: _enum(["read", "unreadable"]),
19253
+ buckets: array(EventDensityBucketSchema).readonly()
19254
+ });
19197
19255
  object({
19198
19256
  trackId: string(),
19199
19257
  className: string(),
@@ -19504,12 +19562,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19504
19562
  since: number(),
19505
19563
  until: number(),
19506
19564
  bucketMs: number().int().positive()
19507
- }), array(object({
19508
- bucketStart: number(),
19509
- motion: number().int(),
19510
- object: number().int(),
19511
- audio: number().int()
19512
- })).readonly()), method(object({
19565
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19566
+ deviceIds: array(number()).min(1).max(200),
19567
+ since: number(),
19568
+ until: number(),
19569
+ bucketMs: number().int().positive()
19570
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19513
19571
  deviceId: number(),
19514
19572
  cutoffMs: number()
19515
19573
  }), object({
@@ -28534,6 +28592,39 @@ var RecordingDaysSchema = object({
28534
28592
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28535
28593
  days: array(number())
28536
28594
  });
28595
+ /**
28596
+ * One camera's row in a `getAvailabilityBatch` answer.
28597
+ *
28598
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28599
+ * batch collapses the transport, not the work — plus the one thing the singular
28600
+ * method never had to say:
28601
+ *
28602
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28603
+ * no footage in the window", which is a real claim.
28604
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28605
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28606
+ * that emptiness means NOTHING.
28607
+ *
28608
+ * A timeline that renders the second as the first tells an operator there is no
28609
+ * footage when the truth is that nobody looked.
28610
+ */
28611
+ var RecordingAvailabilityForDeviceSchema = object({
28612
+ deviceId: number(),
28613
+ read: _enum(["read", "unreadable"]),
28614
+ ranges: array(RecordingRangeSchema).readonly()
28615
+ });
28616
+ /**
28617
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28618
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28619
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28620
+ * the date-picker's day dots must not spell them the same.
28621
+ */
28622
+ var RecordingDaysForDeviceSchema = object({
28623
+ deviceId: number(),
28624
+ read: _enum(["read", "unreadable"]),
28625
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28626
+ days: array(number()).readonly()
28627
+ });
28537
28628
  var RecordingManifestSchema = object({
28538
28629
  deviceId: number(),
28539
28630
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28768,6 +28859,13 @@ method(object({
28768
28859
  }), RecordingAvailabilitySchema, {
28769
28860
  kind: "query",
28770
28861
  auth: "protected"
28862
+ }), method(object({
28863
+ deviceIds: array(number()).min(1).max(200),
28864
+ fromMs: number(),
28865
+ toMs: number()
28866
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28867
+ kind: "query",
28868
+ auth: "protected"
28771
28869
  }), method(object({
28772
28870
  deviceId: number(),
28773
28871
  fromMs: number(),
@@ -28776,6 +28874,14 @@ method(object({
28776
28874
  }), RecordingDaysSchema, {
28777
28875
  kind: "query",
28778
28876
  auth: "protected"
28877
+ }), method(object({
28878
+ deviceIds: array(number()).min(1).max(200),
28879
+ fromMs: number(),
28880
+ toMs: number(),
28881
+ tzOffsetMinutes: number()
28882
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28883
+ kind: "query",
28884
+ auth: "protected"
28779
28885
  }), method(object({
28780
28886
  deviceId: number(),
28781
28887
  fromMs: number(),
@@ -33938,6 +34044,12 @@ Object.freeze({
33938
34044
  addonId: null,
33939
34045
  access: "view"
33940
34046
  },
34047
+ "deviceManager.migrateDevice": {
34048
+ capName: "device-manager",
34049
+ capScope: "system",
34050
+ addonId: null,
34051
+ access: "create"
34052
+ },
33941
34053
  "deviceManager.persistConfig": {
33942
34054
  capName: "device-manager",
33943
34055
  capScope: "system",
@@ -35672,6 +35784,12 @@ Object.freeze({
35672
35784
  addonId: null,
35673
35785
  access: "view"
35674
35786
  },
35787
+ "pipelineAnalytics.getEventDensityBatch": {
35788
+ capName: "pipeline-analytics",
35789
+ capScope: "device",
35790
+ addonId: null,
35791
+ access: "view"
35792
+ },
35675
35793
  "pipelineAnalytics.getEventMedia": {
35676
35794
  capName: "pipeline-analytics",
35677
35795
  capScope: "device",
@@ -36794,12 +36912,24 @@ Object.freeze({
36794
36912
  addonId: null,
36795
36913
  access: "view"
36796
36914
  },
36915
+ "recording.getAvailabilityBatch": {
36916
+ capName: "recording",
36917
+ capScope: "system",
36918
+ addonId: null,
36919
+ access: "view"
36920
+ },
36797
36921
  "recording.getDaysWithRecordings": {
36798
36922
  capName: "recording",
36799
36923
  capScope: "system",
36800
36924
  addonId: null,
36801
36925
  access: "view"
36802
36926
  },
36927
+ "recording.getDaysWithRecordingsBatch": {
36928
+ capName: "recording",
36929
+ capScope: "system",
36930
+ addonId: null,
36931
+ access: "view"
36932
+ },
36803
36933
  "recording.getDeviceConfig": {
36804
36934
  capName: "recording",
36805
36935
  capScope: "system",
@@ -39404,6 +39534,11 @@ Object.freeze({
39404
39534
  form: "single",
39405
39535
  optional: false
39406
39536
  }],
39537
+ "pipelineAnalytics.getEventDensityBatch": [{
39538
+ name: "deviceIds",
39539
+ form: "array",
39540
+ optional: false
39541
+ }],
39407
39542
  "pipelineAnalytics.getEventMedia": [{
39408
39543
  name: "deviceId",
39409
39544
  form: "single",
@@ -39819,11 +39954,21 @@ Object.freeze({
39819
39954
  form: "single",
39820
39955
  optional: false
39821
39956
  }],
39957
+ "recording.getAvailabilityBatch": [{
39958
+ name: "deviceIds",
39959
+ form: "array",
39960
+ optional: false
39961
+ }],
39822
39962
  "recording.getDaysWithRecordings": [{
39823
39963
  name: "deviceId",
39824
39964
  form: "single",
39825
39965
  optional: false
39826
39966
  }],
39967
+ "recording.getDaysWithRecordingsBatch": [{
39968
+ name: "deviceIds",
39969
+ form: "array",
39970
+ optional: false
39971
+ }],
39827
39972
  "recording.getDeviceConfig": [{
39828
39973
  name: "deviceId",
39829
39974
  form: "single",
package/dist/addon.mjs CHANGED
@@ -7720,7 +7720,8 @@ var OpsLogReasonSchema = _enum([
7720
7720
  "quota",
7721
7721
  "manual",
7722
7722
  "operator",
7723
- "maintenance"
7723
+ "maintenance",
7724
+ "orphaned-device"
7724
7725
  ]);
7725
7726
  /** One audit row, shared verbatim by both domains. */
7726
7727
  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(), {
@@ -19193,6 +19223,34 @@ var KeyEventsForDeviceSchema = object({
19193
19223
  deviceId: number(),
19194
19224
  events: array(KeyEventSchema).readonly()
19195
19225
  });
19226
+ /** One non-empty bucket of the timeline histogram. */
19227
+ var EventDensityBucketSchema = object({
19228
+ bucketStart: number(),
19229
+ motion: number().int(),
19230
+ object: number().int(),
19231
+ audio: number().int()
19232
+ });
19233
+ /**
19234
+ * One camera's row in a `getEventDensityBatch` answer.
19235
+ *
19236
+ * TWO facts, and the timeline draws them differently:
19237
+ *
19238
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
19239
+ * claim: read, and nothing happened in the window.
19240
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
19241
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
19242
+ * not quiet.
19243
+ *
19244
+ * Fanned out per camera the difference was free — one query rejected while the
19245
+ * others resolved — and collapsing to a batch is the exact moment it is lost
19246
+ * silently. Every requested id gets a row: a caller that asked for twelve and
19247
+ * got eleven cannot tell which one is missing, or that any is.
19248
+ */
19249
+ var EventDensityForDeviceSchema = object({
19250
+ deviceId: number(),
19251
+ read: _enum(["read", "unreadable"]),
19252
+ buckets: array(EventDensityBucketSchema).readonly()
19253
+ });
19196
19254
  object({
19197
19255
  trackId: string(),
19198
19256
  className: string(),
@@ -19503,12 +19561,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19503
19561
  since: number(),
19504
19562
  until: number(),
19505
19563
  bucketMs: number().int().positive()
19506
- }), array(object({
19507
- bucketStart: number(),
19508
- motion: number().int(),
19509
- object: number().int(),
19510
- audio: number().int()
19511
- })).readonly()), method(object({
19564
+ }), array(EventDensityBucketSchema).readonly()), method(object({
19565
+ deviceIds: array(number()).min(1).max(200),
19566
+ since: number(),
19567
+ until: number(),
19568
+ bucketMs: number().int().positive()
19569
+ }), array(EventDensityForDeviceSchema).readonly()), method(object({
19512
19570
  deviceId: number(),
19513
19571
  cutoffMs: number()
19514
19572
  }), object({
@@ -28533,6 +28591,39 @@ var RecordingDaysSchema = object({
28533
28591
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28534
28592
  days: array(number())
28535
28593
  });
28594
+ /**
28595
+ * One camera's row in a `getAvailabilityBatch` answer.
28596
+ *
28597
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
28598
+ * batch collapses the transport, not the work — plus the one thing the singular
28599
+ * method never had to say:
28600
+ *
28601
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
28602
+ * no footage in the window", which is a real claim.
28603
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
28604
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
28605
+ * that emptiness means NOTHING.
28606
+ *
28607
+ * A timeline that renders the second as the first tells an operator there is no
28608
+ * footage when the truth is that nobody looked.
28609
+ */
28610
+ var RecordingAvailabilityForDeviceSchema = object({
28611
+ deviceId: number(),
28612
+ read: _enum(["read", "unreadable"]),
28613
+ ranges: array(RecordingRangeSchema).readonly()
28614
+ });
28615
+ /**
28616
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
28617
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
28618
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
28619
+ * the date-picker's day dots must not spell them the same.
28620
+ */
28621
+ var RecordingDaysForDeviceSchema = object({
28622
+ deviceId: number(),
28623
+ read: _enum(["read", "unreadable"]),
28624
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
28625
+ days: array(number()).readonly()
28626
+ });
28536
28627
  var RecordingManifestSchema = object({
28537
28628
  deviceId: number(),
28538
28629
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -28767,6 +28858,13 @@ method(object({
28767
28858
  }), RecordingAvailabilitySchema, {
28768
28859
  kind: "query",
28769
28860
  auth: "protected"
28861
+ }), method(object({
28862
+ deviceIds: array(number()).min(1).max(200),
28863
+ fromMs: number(),
28864
+ toMs: number()
28865
+ }), array(RecordingAvailabilityForDeviceSchema).readonly(), {
28866
+ kind: "query",
28867
+ auth: "protected"
28770
28868
  }), method(object({
28771
28869
  deviceId: number(),
28772
28870
  fromMs: number(),
@@ -28775,6 +28873,14 @@ method(object({
28775
28873
  }), RecordingDaysSchema, {
28776
28874
  kind: "query",
28777
28875
  auth: "protected"
28876
+ }), method(object({
28877
+ deviceIds: array(number()).min(1).max(200),
28878
+ fromMs: number(),
28879
+ toMs: number(),
28880
+ tzOffsetMinutes: number()
28881
+ }), array(RecordingDaysForDeviceSchema).readonly(), {
28882
+ kind: "query",
28883
+ auth: "protected"
28778
28884
  }), method(object({
28779
28885
  deviceId: number(),
28780
28886
  fromMs: number(),
@@ -33937,6 +34043,12 @@ Object.freeze({
33937
34043
  addonId: null,
33938
34044
  access: "view"
33939
34045
  },
34046
+ "deviceManager.migrateDevice": {
34047
+ capName: "device-manager",
34048
+ capScope: "system",
34049
+ addonId: null,
34050
+ access: "create"
34051
+ },
33940
34052
  "deviceManager.persistConfig": {
33941
34053
  capName: "device-manager",
33942
34054
  capScope: "system",
@@ -35671,6 +35783,12 @@ Object.freeze({
35671
35783
  addonId: null,
35672
35784
  access: "view"
35673
35785
  },
35786
+ "pipelineAnalytics.getEventDensityBatch": {
35787
+ capName: "pipeline-analytics",
35788
+ capScope: "device",
35789
+ addonId: null,
35790
+ access: "view"
35791
+ },
35674
35792
  "pipelineAnalytics.getEventMedia": {
35675
35793
  capName: "pipeline-analytics",
35676
35794
  capScope: "device",
@@ -36793,12 +36911,24 @@ Object.freeze({
36793
36911
  addonId: null,
36794
36912
  access: "view"
36795
36913
  },
36914
+ "recording.getAvailabilityBatch": {
36915
+ capName: "recording",
36916
+ capScope: "system",
36917
+ addonId: null,
36918
+ access: "view"
36919
+ },
36796
36920
  "recording.getDaysWithRecordings": {
36797
36921
  capName: "recording",
36798
36922
  capScope: "system",
36799
36923
  addonId: null,
36800
36924
  access: "view"
36801
36925
  },
36926
+ "recording.getDaysWithRecordingsBatch": {
36927
+ capName: "recording",
36928
+ capScope: "system",
36929
+ addonId: null,
36930
+ access: "view"
36931
+ },
36802
36932
  "recording.getDeviceConfig": {
36803
36933
  capName: "recording",
36804
36934
  capScope: "system",
@@ -39403,6 +39533,11 @@ Object.freeze({
39403
39533
  form: "single",
39404
39534
  optional: false
39405
39535
  }],
39536
+ "pipelineAnalytics.getEventDensityBatch": [{
39537
+ name: "deviceIds",
39538
+ form: "array",
39539
+ optional: false
39540
+ }],
39406
39541
  "pipelineAnalytics.getEventMedia": [{
39407
39542
  name: "deviceId",
39408
39543
  form: "single",
@@ -39818,11 +39953,21 @@ Object.freeze({
39818
39953
  form: "single",
39819
39954
  optional: false
39820
39955
  }],
39956
+ "recording.getAvailabilityBatch": [{
39957
+ name: "deviceIds",
39958
+ form: "array",
39959
+ optional: false
39960
+ }],
39821
39961
  "recording.getDaysWithRecordings": [{
39822
39962
  name: "deviceId",
39823
39963
  form: "single",
39824
39964
  optional: false
39825
39965
  }],
39966
+ "recording.getDaysWithRecordingsBatch": [{
39967
+ name: "deviceIds",
39968
+ form: "array",
39969
+ optional: false
39970
+ }],
39826
39971
  "recording.getDeviceConfig": [{
39827
39972
  name: "deviceId",
39828
39973
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-gree",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "description": "Gree air-conditioner device-provider addon for CamStack — wraps the @apocaliss92/nodegree local-UDP client (LAN discovery + AES control), exposing climate-control and fan-control",
5
5
  "keywords": [
6
6
  "camstack",