@camstack/addon-provider-tuya 0.2.25 → 0.2.27

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.
Files changed (3) hide show
  1. package/dist/addon.js +258 -17
  2. package/dist/addon.mjs +258 -17
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -819,7 +819,7 @@ async function discoverDevices(options = {}, binder = createDgramBinder()) {
819
819
  return [...byId.values()];
820
820
  }
821
821
  //#endregion
822
- //#region ../types/dist/event-category-XfKNtfCc.mjs
822
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
823
823
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
824
824
  EventCategory["SystemBoot"] = "system.boot";
825
825
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -835,6 +835,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
835
835
  */
836
836
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
837
837
  /**
838
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
839
+ * Emitted only when the material on disk actually changed, so an
840
+ * operator who trusted the old certificate by hand is told rather than
841
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
842
+ *
843
+ * Rule: docs/decisions/adr-0227-*.md
844
+ */
845
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
846
+ /**
838
847
  * A newer addon or server-root package version was found by the
839
848
  * authoritative registry check. Emitted once when any observed
840
849
  * `latestVersion` changes (or a package/node first appears behind);
@@ -8831,6 +8840,15 @@ var LabelDefinitionSchema = object({
8831
8840
  description: string().optional(),
8832
8841
  icon: string().optional()
8833
8842
  });
8843
+ var ClassMapDefinitionSchema = object({
8844
+ mapping: record(string(), _enum([
8845
+ "person",
8846
+ "vehicle",
8847
+ "animal",
8848
+ "package"
8849
+ ])),
8850
+ preserveOriginal: boolean()
8851
+ });
8834
8852
  var MODEL_FORMATS = [
8835
8853
  "onnx",
8836
8854
  "coreml",
@@ -9009,7 +9027,13 @@ var ModelCatalogEntrySchema = object({
9009
9027
  * `id` stays the source of truth for resolution/download/persistence; grouping
9010
9028
  * is a presentation overlay resolved back to an `id`.
9011
9029
  */
9012
- group: ModelVariantGroupSchema.optional()
9030
+ group: ModelVariantGroupSchema.optional(),
9031
+ /**
9032
+ * Per-MODEL class map override. Absent ⇒ the step's `StepDefinition.classMap`
9033
+ * applies (Frigate / COCO public catalog). Set on a custom model whose raw
9034
+ * labels already ARE the CamStack macros (Scrypted identity map).
9035
+ */
9036
+ classMap: ClassMapDefinitionSchema.optional()
9013
9037
  });
9014
9038
  var ConvertTargetSchema = discriminatedUnion("format", [object({
9015
9039
  format: literal("openvino"),
@@ -9038,7 +9062,8 @@ var ModelConvertMetadataSchema = object({
9038
9062
  "ocr",
9039
9063
  "segmentation"
9040
9064
  ]),
9041
- faceAlignment: boolean().optional()
9065
+ faceAlignment: boolean().optional(),
9066
+ classMap: ClassMapDefinitionSchema.optional()
9042
9067
  });
9043
9068
  var ConvertResultSchema = object({
9044
9069
  entry: ModelCatalogEntrySchema,
@@ -18348,6 +18373,33 @@ var NativeCropRefSchema = object({
18348
18373
  h: number()
18349
18374
  })
18350
18375
  });
18376
+ object({
18377
+ crop: object({
18378
+ left: number(),
18379
+ top: number(),
18380
+ width: number().positive(),
18381
+ height: number().positive()
18382
+ }).optional(),
18383
+ content: object({
18384
+ width: number().int().positive(),
18385
+ height: number().int().positive()
18386
+ }),
18387
+ fit: _enum(["stretch", "contain"]),
18388
+ format: _enum([
18389
+ "rgb",
18390
+ "gray",
18391
+ "jpeg"
18392
+ ])
18393
+ });
18394
+ var FrameRefSchema = object({
18395
+ registryId: string().min(1),
18396
+ id: string().min(1),
18397
+ width: number().int().positive(),
18398
+ height: number().int().positive(),
18399
+ format: _enum(["rgb", "gray"]),
18400
+ timestamp: number(),
18401
+ capturedAt: number().optional()
18402
+ });
18351
18403
  var ModelFormatSchema$1 = _enum([
18352
18404
  "onnx",
18353
18405
  "coreml",
@@ -18592,6 +18644,12 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
18592
18644
  steps: array(PipelineStepInputSchema).min(1),
18593
18645
  frame: FrameInputSchema.optional(),
18594
18646
  /**
18647
+ * Process-local lazy frame. Valid only when caller and provider resolve
18648
+ * in the same execution-group process; split/cross-node callers use
18649
+ * `frame`/`image` inline compatibility instead.
18650
+ */
18651
+ frameRef: FrameRefSchema.optional(),
18652
+ /**
18595
18653
  * CB5 shm passthrough — a `FrameHandle` naming the same ring slot
18596
18654
  * the decoded pixels live in. One more member of the one-of
18597
18655
  * frame/frameHandle/image/imageBase64/referenceImage group.
@@ -18887,7 +18945,10 @@ var NativeCropResultSchema = object({
18887
18945
  * Which source served this crop, so a quality-sensitive consumer (the native
18888
18946
  * `keyFrame`) can reject a degraded fallback:
18889
18947
  * - `native` — cut from the decode worker's retained NATIVE surface (the
18890
- * quality path).
18948
+ * quality path). A subject-tile serve is also native-resolution and stays
18949
+ * `native` here: the public enum cannot name `tile` without a breaking cap
18950
+ * change. Runner telemetry distinguishes lease vs tile via `source` on the
18951
+ * internal crop result (`nativeHits` vs `tileHits`).
18891
18952
  * - `ram-fullframe` — the native surface MISSED but the request was full-frame,
18892
18953
  * so the ≤640 RAM `RetainedFrameStore` served it (honest lower-res; legit for
18893
18954
  * the blank-frame guard / 640-snapshot resolve, NOT for the clean keyFrame).
@@ -19378,12 +19439,41 @@ var RunnerLocalLoadSchema = object({
19378
19439
  * legacy `OrchestratorMetricsSchema` shape so existing dashboards keep
19379
19440
  * working unchanged when they switch to reading from the runner cap.
19380
19441
  */
19442
+ var FrameLazyCountersSchema = object({
19443
+ framesDecoded: number(),
19444
+ framesAdmitted: number(),
19445
+ framesDroppedPixelFree: number(),
19446
+ viewsMaterialized: number(),
19447
+ viewsSkipped: number(),
19448
+ workerToRunnerBytes: number(),
19449
+ runnerToPoolRawBytes: number(),
19450
+ runnerToPoolJpegBytes: number(),
19451
+ onDemandFullFrameRequests: number(),
19452
+ onDemandCropRequests: number(),
19453
+ nativeHits: number(),
19454
+ nativeMisses: number(),
19455
+ tileHits: number(),
19456
+ tileMisses: number(),
19457
+ fallbackHits: number(),
19458
+ fallbackMisses: number(),
19459
+ retainedWritesAvoided: number(),
19460
+ residentRefs: number(),
19461
+ residentBytes: number(),
19462
+ releases: number(),
19463
+ evictions: number(),
19464
+ staleMisses: number()
19465
+ });
19466
+ var FrameLazyMetricsSchema = object({
19467
+ node: FrameLazyCountersSchema,
19468
+ cameras: array(FrameLazyCountersSchema.extend({ deviceId: number() }))
19469
+ });
19381
19470
  var RunnerLocalMetricsSchema = object({
19382
19471
  nodeId: string(),
19383
19472
  activeCameras: number(),
19384
19473
  throttledCameras: number(),
19385
19474
  avgInferenceTimeMs: number(),
19386
- queueDepth: number()
19475
+ queueDepth: number(),
19476
+ frameLazy: FrameLazyMetricsSchema.optional()
19387
19477
  });
19388
19478
  method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mutation" }), method(object({ deviceId: number() }), object({ success: literal(true) }), { kind: "mutation" }), method(ReportMotionInputSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), RunnerLocalLoadSchema), method(_void(), RunnerLocalMetricsSchema), method(object({ deviceId: number() }), CameraMetricsSchema.nullable()), method(_void(), array(CameraMetricsWithDeviceIdSchema).readonly()), method(_void(), array(number()).readonly()), method(object({
19389
19479
  handle: FrameHandleSchema,
@@ -20683,6 +20773,9 @@ method(_void(), ProviderInfoSchema), method(object({ config: record(string(), un
20683
20773
  location: StorageLocationSchema,
20684
20774
  relativePath: string()
20685
20775
  }), _void(), { kind: "mutation" }), method(object({ location: StorageLocationSchema }), number().nullable()), method(BeginUploadInputSchema, BeginUploadResultSchema, { kind: "mutation" }), method(WriteChunkInputSchema, _void(), { kind: "mutation" }), method(FinalizeUploadInputSchema, _void(), { kind: "mutation" }), method(AbortUploadInputSchema, _void(), { kind: "mutation" }), method(BeginDownloadInputSchema, BeginDownloadResultSchema, { kind: "mutation" }), method(ReadChunkInputSchema, _instanceof(Uint8Array)), method(EndDownloadInputSchema, _void(), { kind: "mutation" });
20776
+ /** Profile-exported FormBuilder schema. Shape is ConfigUISchema at the UI. */
20777
+ var ProfileSettingsSchemaBridge = unknown().nullable();
20778
+ var ProfileSettingsBagSchema = record(string(), unknown());
20686
20779
  /**
20687
20780
  * A live terminal session hosted by the provider addon. Output and input do
20688
20781
  * NOT flow through the capability — they use the addon data plane
@@ -20712,7 +20805,14 @@ var TerminalSessionInfoSchema = object({
20712
20805
  var TerminalProfileInfoSchema = object({
20713
20806
  profileId: string(),
20714
20807
  label: string(),
20715
- description: string().optional()
20808
+ description: string().optional(),
20809
+ /** Spawn defaults the instance form copies on create. */
20810
+ executable: string().optional(),
20811
+ args: array(string()).readonly().optional(),
20812
+ cwd: string().optional(),
20813
+ environment: array(string()).readonly().optional(),
20814
+ /** ConfigUISchema for instance knobs, or null when the profile has none. */
20815
+ settingsSchema: ProfileSettingsSchemaBridge.optional()
20716
20816
  });
20717
20817
  /**
20718
20818
  * A durable operator-created Terminal instance. Profiles are templates; only
@@ -20725,7 +20825,12 @@ var TerminalInstanceInfoSchema = object({
20725
20825
  profileId: string(),
20726
20826
  profileLabel: string(),
20727
20827
  name: string(),
20728
- enabled: boolean()
20828
+ enabled: boolean(),
20829
+ executable: string(),
20830
+ args: array(string()).readonly(),
20831
+ cwd: string(),
20832
+ environment: array(string()).readonly(),
20833
+ profileSettings: ProfileSettingsBagSchema
20729
20834
  });
20730
20835
  var TerminalLegacyCameraSchema = object({
20731
20836
  stableId: string(),
@@ -20755,7 +20860,23 @@ var TerminalOutputBatchSchema = object({
20755
20860
  method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
20756
20861
  targetNodeId: string().min(1),
20757
20862
  profileId: string().min(1),
20758
- name: string().trim().min(1).max(160).optional()
20863
+ name: string().trim().min(1).max(160).optional(),
20864
+ executable: string().max(1024).optional(),
20865
+ args: array(string().max(2048)).max(64).optional(),
20866
+ cwd: string().max(1024).optional(),
20867
+ environment: array(string().max(4096)).max(64).optional(),
20868
+ profileSettings: ProfileSettingsBagSchema.optional()
20869
+ }), TerminalInstanceInfoSchema, {
20870
+ kind: "mutation",
20871
+ auth: "admin"
20872
+ }), method(object({
20873
+ instanceId: string().min(1),
20874
+ name: string().trim().min(1).max(160).optional(),
20875
+ executable: string().max(1024).optional(),
20876
+ args: array(string().max(2048)).max(64).optional(),
20877
+ cwd: string().max(1024).optional(),
20878
+ environment: array(string().max(4096)).max(64).optional(),
20879
+ profileSettings: ProfileSettingsBagSchema.optional()
20759
20880
  }), TerminalInstanceInfoSchema, {
20760
20881
  kind: "mutation",
20761
20882
  auth: "admin"
@@ -20777,7 +20898,11 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
20777
20898
  }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
20778
20899
  profileId: string(),
20779
20900
  cols: number().int().positive(),
20780
- rows: number().int().positive()
20901
+ rows: number().int().positive(),
20902
+ executable: string().max(1024).optional(),
20903
+ args: array(string().max(2048)).max(64).optional(),
20904
+ cwd: string().max(1024).optional(),
20905
+ environment: array(string().max(4096)).max(64).optional()
20781
20906
  }), TerminalSessionInfoSchema, {
20782
20907
  kind: "mutation",
20783
20908
  auth: "admin"
@@ -24660,10 +24785,10 @@ var lawnMowerControlCapability = {
24660
24785
  *
24661
24786
  * • The SDK (mobile / web client) consumes `getConnectionEndpoints()`
24662
24787
  * to receive an ordered list of candidate base URLs it should race
24663
- * on connect — LAN IPv4 first (lowest latency when on same network),
24664
- * then public hostname (if a tunnel is up), then IPv6. The SDK can
24665
- * race them with short timeouts and stick with the winner for the
24666
- * session.
24788
+ * on connect — LAN IPv4 and stable LAN IPv6 first (lowest latency
24789
+ * when on the same network), then public hostname (if a tunnel is
24790
+ * up). The SDK can race them with short timeouts and stick with the
24791
+ * winner for the session.
24667
24792
  *
24668
24793
  * Why hub-only: agents are not directly addressable by the operator's
24669
24794
  * clients — they reverse-connect to the hub. Exposing their interfaces
@@ -24818,6 +24943,17 @@ var NotificationEndpointSchema = object({
24818
24943
  /** What the ranking currently resolves to (null when nothing is reachable). */
24819
24944
  resolved: string().nullable()
24820
24945
  });
24946
+ /**
24947
+ * The URLs the SDK / viewer should race for API access. `baseUrls` empty =
24948
+ * AUTO (every LAN IPv4 + the public tunnel). `resolved` is what that choice
24949
+ * currently expands to, so the UI can show the effective set either way.
24950
+ */
24951
+ var ViewerEndpointsSchema = object({
24952
+ /** The operator's explicit race set, or empty for AUTO. */
24953
+ baseUrls: array(string()).readonly(),
24954
+ /** What the ranking currently resolves to (may be empty if nothing is up). */
24955
+ resolved: array(string()).readonly()
24956
+ });
24821
24957
  var AllowedAddressesSchema = object({
24822
24958
  /**
24823
24959
  * Allowlist of interface addresses operators have explicitly opted
@@ -24826,6 +24962,20 @@ var AllowedAddressesSchema = object({
24826
24962
  * Network Addresses admin page and persisted by the addon.
24827
24963
  */
24828
24964
  addresses: array(string()).readonly() });
24965
+ var TlsStatusSchema = object({
24966
+ mode: _enum([
24967
+ "generated",
24968
+ "uploaded",
24969
+ "disabled"
24970
+ ]),
24971
+ leafFingerprintSha256: string().nullable(),
24972
+ caFingerprintSha256: string().nullable(),
24973
+ validTo: string().nullable(),
24974
+ sans: array(string()),
24975
+ caCertPem: string().nullable(),
24976
+ reissueError: string().nullable(),
24977
+ restartRequired: boolean()
24978
+ });
24829
24979
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24830
24980
  /**
24831
24981
  * LEGACY HINT — do not send from new code. Kept optional so clients
@@ -24835,17 +24985,31 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
24835
24985
  */
24836
24986
  port: number().int().min(1).max(65535).optional(),
24837
24987
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24838
- * candidate. Default `true`. */
24988
+ * candidate. Default `false` — loopback is not a client route. */
24839
24989
  includeLoopback: boolean().optional(),
24840
- /** Skip IPv6 entries. Some legacy clients can't parse them.
24841
- * Default `false`. */
24990
+ /** Skip IPv6 entries. Default `false` the palette includes stable
24991
+ * LAN IPv6 (ICE/WebRTC uses dual-stack regardless). Pass `true` to
24992
+ * hide them. The viewer HTTP/WS race is `getViewerEndpoints`. */
24842
24993
  ipv4Only: boolean().optional(),
24843
24994
  /** Scheme to emit for LAN/loopback URLs. Default `'http'`.
24844
24995
  * Pass `'https'` when the caller is itself loaded over HTTPS
24845
24996
  * to avoid mixed-content blocks in the browser. The public
24846
24997
  * tunnel always emits `https://` regardless. */
24847
24998
  scheme: _enum(["http", "https"]).optional()
24848
- }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
24999
+ }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), ViewerEndpointsSchema), method(object({ baseUrls: array(string()).readonly() }), ViewerEndpointsSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" }), method(_void(), TlsStatusSchema), method(object({ reason: string().optional() }), TlsStatusSchema, {
25000
+ kind: "mutation",
25001
+ auth: "admin"
25002
+ }), method(object({
25003
+ certPem: string().min(1),
25004
+ keyPem: string().min(1),
25005
+ caPem: string().optional()
25006
+ }), TlsStatusSchema, {
25007
+ kind: "mutation",
25008
+ auth: "admin"
25009
+ }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
25010
+ kind: "mutation",
25011
+ auth: "admin"
25012
+ });
24849
25013
  var LockControlStatusSchema = object({
24850
25014
  /** Lifecycle state of the lock. `jammed` means the motor reported
24851
25015
  * failure to reach the target — operator intervention required. */
@@ -32872,6 +33036,12 @@ Object.freeze({
32872
33036
  addonId: null,
32873
33037
  access: "create"
32874
33038
  },
33039
+ "localNetwork.downloadCa": {
33040
+ capName: "local-network",
33041
+ capScope: "system",
33042
+ addonId: null,
33043
+ access: "view"
33044
+ },
32875
33045
  "localNetwork.getAllowedAddresses": {
32876
33046
  capName: "local-network",
32877
33047
  capScope: "system",
@@ -32896,18 +33066,42 @@ Object.freeze({
32896
33066
  addonId: null,
32897
33067
  access: "view"
32898
33068
  },
33069
+ "localNetwork.getTlsStatus": {
33070
+ capName: "local-network",
33071
+ capScope: "system",
33072
+ addonId: null,
33073
+ access: "view"
33074
+ },
33075
+ "localNetwork.getViewerEndpoints": {
33076
+ capName: "local-network",
33077
+ capScope: "system",
33078
+ addonId: null,
33079
+ access: "view"
33080
+ },
32899
33081
  "localNetwork.list": {
32900
33082
  capName: "local-network",
32901
33083
  capScope: "system",
32902
33084
  addonId: null,
32903
33085
  access: "view"
32904
33086
  },
33087
+ "localNetwork.regenerateCertificate": {
33088
+ capName: "local-network",
33089
+ capScope: "system",
33090
+ addonId: null,
33091
+ access: "create"
33092
+ },
32905
33093
  "localNetwork.resetAllowlistToBestMatch": {
32906
33094
  capName: "local-network",
32907
33095
  capScope: "system",
32908
33096
  addonId: null,
32909
33097
  access: "delete"
32910
33098
  },
33099
+ "localNetwork.revertToGeneratedCertificate": {
33100
+ capName: "local-network",
33101
+ capScope: "system",
33102
+ addonId: null,
33103
+ access: "create"
33104
+ },
32911
33105
  "localNetwork.setAllowedAddresses": {
32912
33106
  capName: "local-network",
32913
33107
  capScope: "system",
@@ -32920,6 +33114,18 @@ Object.freeze({
32920
33114
  addonId: null,
32921
33115
  access: "create"
32922
33116
  },
33117
+ "localNetwork.setViewerEndpoints": {
33118
+ capName: "local-network",
33119
+ capScope: "system",
33120
+ addonId: null,
33121
+ access: "create"
33122
+ },
33123
+ "localNetwork.uploadCertificate": {
33124
+ capName: "local-network",
33125
+ capScope: "system",
33126
+ addonId: null,
33127
+ access: "create"
33128
+ },
32923
33129
  "lockControl.lock": {
32924
33130
  capName: "lock-control",
32925
33131
  capScope: "device",
@@ -35800,6 +36006,12 @@ Object.freeze({
35800
36006
  addonId: null,
35801
36007
  access: "create"
35802
36008
  },
36009
+ "terminalSession.updateInstance": {
36010
+ capName: "terminal-session",
36011
+ capScope: "system",
36012
+ addonId: null,
36013
+ access: "create"
36014
+ },
35803
36015
  "terminalSession.writeInput": {
35804
36016
  capName: "terminal-session",
35805
36017
  capScope: "system",
@@ -38287,6 +38499,35 @@ Object.freeze(Object.fromEntries([{
38287
38499
  }]
38288
38500
  }].map((s) => [s.stepId, s.defaultModelId])));
38289
38501
  string().min(1);
38502
+ var CLUSTER_STEP_SETTING_FIELDS = [{
38503
+ stepId: "face-embedding",
38504
+ key: "minLandmarkFaceSize",
38505
+ label: "Min face size for recognition (detection px)",
38506
+ description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
38507
+ type: "slider",
38508
+ min: 0,
38509
+ max: 64,
38510
+ step: 2,
38511
+ default: 24
38512
+ }];
38513
+ function clusterStepSettingKey(stepId, fieldKey) {
38514
+ return `clusterStepSetting:${stepId}:${fieldKey}`;
38515
+ }
38516
+ var ClusterSettingNumberSchema = number().finite();
38517
+ function readClusterStepSettings(config) {
38518
+ const out = {};
38519
+ for (const field of CLUSTER_STEP_SETTING_FIELDS) {
38520
+ const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
38521
+ const value = parsed.success ? parsed.data : field.default;
38522
+ const existing = out[field.stepId] ?? {};
38523
+ out[field.stepId] = {
38524
+ ...existing,
38525
+ [field.key]: value
38526
+ };
38527
+ }
38528
+ return out;
38529
+ }
38530
+ readClusterStepSettings({});
38290
38531
  object({
38291
38532
  /**
38292
38533
  * Fraction of the box's own size added on EACH side before cutting.
package/dist/addon.mjs CHANGED
@@ -818,7 +818,7 @@ async function discoverDevices(options = {}, binder = createDgramBinder()) {
818
818
  return [...byId.values()];
819
819
  }
820
820
  //#endregion
821
- //#region ../types/dist/event-category-XfKNtfCc.mjs
821
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
822
822
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
823
823
  EventCategory["SystemBoot"] = "system.boot";
824
824
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -834,6 +834,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
834
834
  */
835
835
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
836
836
  /**
837
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
838
+ * Emitted only when the material on disk actually changed, so an
839
+ * operator who trusted the old certificate by hand is told rather than
840
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
841
+ *
842
+ * Rule: docs/decisions/adr-0227-*.md
843
+ */
844
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
845
+ /**
837
846
  * A newer addon or server-root package version was found by the
838
847
  * authoritative registry check. Emitted once when any observed
839
848
  * `latestVersion` changes (or a package/node first appears behind);
@@ -8830,6 +8839,15 @@ var LabelDefinitionSchema = object({
8830
8839
  description: string().optional(),
8831
8840
  icon: string().optional()
8832
8841
  });
8842
+ var ClassMapDefinitionSchema = object({
8843
+ mapping: record(string(), _enum([
8844
+ "person",
8845
+ "vehicle",
8846
+ "animal",
8847
+ "package"
8848
+ ])),
8849
+ preserveOriginal: boolean()
8850
+ });
8833
8851
  var MODEL_FORMATS = [
8834
8852
  "onnx",
8835
8853
  "coreml",
@@ -9008,7 +9026,13 @@ var ModelCatalogEntrySchema = object({
9008
9026
  * `id` stays the source of truth for resolution/download/persistence; grouping
9009
9027
  * is a presentation overlay resolved back to an `id`.
9010
9028
  */
9011
- group: ModelVariantGroupSchema.optional()
9029
+ group: ModelVariantGroupSchema.optional(),
9030
+ /**
9031
+ * Per-MODEL class map override. Absent ⇒ the step's `StepDefinition.classMap`
9032
+ * applies (Frigate / COCO public catalog). Set on a custom model whose raw
9033
+ * labels already ARE the CamStack macros (Scrypted identity map).
9034
+ */
9035
+ classMap: ClassMapDefinitionSchema.optional()
9012
9036
  });
9013
9037
  var ConvertTargetSchema = discriminatedUnion("format", [object({
9014
9038
  format: literal("openvino"),
@@ -9037,7 +9061,8 @@ var ModelConvertMetadataSchema = object({
9037
9061
  "ocr",
9038
9062
  "segmentation"
9039
9063
  ]),
9040
- faceAlignment: boolean().optional()
9064
+ faceAlignment: boolean().optional(),
9065
+ classMap: ClassMapDefinitionSchema.optional()
9041
9066
  });
9042
9067
  var ConvertResultSchema = object({
9043
9068
  entry: ModelCatalogEntrySchema,
@@ -18347,6 +18372,33 @@ var NativeCropRefSchema = object({
18347
18372
  h: number()
18348
18373
  })
18349
18374
  });
18375
+ object({
18376
+ crop: object({
18377
+ left: number(),
18378
+ top: number(),
18379
+ width: number().positive(),
18380
+ height: number().positive()
18381
+ }).optional(),
18382
+ content: object({
18383
+ width: number().int().positive(),
18384
+ height: number().int().positive()
18385
+ }),
18386
+ fit: _enum(["stretch", "contain"]),
18387
+ format: _enum([
18388
+ "rgb",
18389
+ "gray",
18390
+ "jpeg"
18391
+ ])
18392
+ });
18393
+ var FrameRefSchema = object({
18394
+ registryId: string().min(1),
18395
+ id: string().min(1),
18396
+ width: number().int().positive(),
18397
+ height: number().int().positive(),
18398
+ format: _enum(["rgb", "gray"]),
18399
+ timestamp: number(),
18400
+ capturedAt: number().optional()
18401
+ });
18350
18402
  var ModelFormatSchema$1 = _enum([
18351
18403
  "onnx",
18352
18404
  "coreml",
@@ -18591,6 +18643,12 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
18591
18643
  steps: array(PipelineStepInputSchema).min(1),
18592
18644
  frame: FrameInputSchema.optional(),
18593
18645
  /**
18646
+ * Process-local lazy frame. Valid only when caller and provider resolve
18647
+ * in the same execution-group process; split/cross-node callers use
18648
+ * `frame`/`image` inline compatibility instead.
18649
+ */
18650
+ frameRef: FrameRefSchema.optional(),
18651
+ /**
18594
18652
  * CB5 shm passthrough — a `FrameHandle` naming the same ring slot
18595
18653
  * the decoded pixels live in. One more member of the one-of
18596
18654
  * frame/frameHandle/image/imageBase64/referenceImage group.
@@ -18886,7 +18944,10 @@ var NativeCropResultSchema = object({
18886
18944
  * Which source served this crop, so a quality-sensitive consumer (the native
18887
18945
  * `keyFrame`) can reject a degraded fallback:
18888
18946
  * - `native` — cut from the decode worker's retained NATIVE surface (the
18889
- * quality path).
18947
+ * quality path). A subject-tile serve is also native-resolution and stays
18948
+ * `native` here: the public enum cannot name `tile` without a breaking cap
18949
+ * change. Runner telemetry distinguishes lease vs tile via `source` on the
18950
+ * internal crop result (`nativeHits` vs `tileHits`).
18890
18951
  * - `ram-fullframe` — the native surface MISSED but the request was full-frame,
18891
18952
  * so the ≤640 RAM `RetainedFrameStore` served it (honest lower-res; legit for
18892
18953
  * the blank-frame guard / 640-snapshot resolve, NOT for the clean keyFrame).
@@ -19377,12 +19438,41 @@ var RunnerLocalLoadSchema = object({
19377
19438
  * legacy `OrchestratorMetricsSchema` shape so existing dashboards keep
19378
19439
  * working unchanged when they switch to reading from the runner cap.
19379
19440
  */
19441
+ var FrameLazyCountersSchema = object({
19442
+ framesDecoded: number(),
19443
+ framesAdmitted: number(),
19444
+ framesDroppedPixelFree: number(),
19445
+ viewsMaterialized: number(),
19446
+ viewsSkipped: number(),
19447
+ workerToRunnerBytes: number(),
19448
+ runnerToPoolRawBytes: number(),
19449
+ runnerToPoolJpegBytes: number(),
19450
+ onDemandFullFrameRequests: number(),
19451
+ onDemandCropRequests: number(),
19452
+ nativeHits: number(),
19453
+ nativeMisses: number(),
19454
+ tileHits: number(),
19455
+ tileMisses: number(),
19456
+ fallbackHits: number(),
19457
+ fallbackMisses: number(),
19458
+ retainedWritesAvoided: number(),
19459
+ residentRefs: number(),
19460
+ residentBytes: number(),
19461
+ releases: number(),
19462
+ evictions: number(),
19463
+ staleMisses: number()
19464
+ });
19465
+ var FrameLazyMetricsSchema = object({
19466
+ node: FrameLazyCountersSchema,
19467
+ cameras: array(FrameLazyCountersSchema.extend({ deviceId: number() }))
19468
+ });
19380
19469
  var RunnerLocalMetricsSchema = object({
19381
19470
  nodeId: string(),
19382
19471
  activeCameras: number(),
19383
19472
  throttledCameras: number(),
19384
19473
  avgInferenceTimeMs: number(),
19385
- queueDepth: number()
19474
+ queueDepth: number(),
19475
+ frameLazy: FrameLazyMetricsSchema.optional()
19386
19476
  });
19387
19477
  method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mutation" }), method(object({ deviceId: number() }), object({ success: literal(true) }), { kind: "mutation" }), method(ReportMotionInputSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), RunnerLocalLoadSchema), method(_void(), RunnerLocalMetricsSchema), method(object({ deviceId: number() }), CameraMetricsSchema.nullable()), method(_void(), array(CameraMetricsWithDeviceIdSchema).readonly()), method(_void(), array(number()).readonly()), method(object({
19388
19478
  handle: FrameHandleSchema,
@@ -20682,6 +20772,9 @@ method(_void(), ProviderInfoSchema), method(object({ config: record(string(), un
20682
20772
  location: StorageLocationSchema,
20683
20773
  relativePath: string()
20684
20774
  }), _void(), { kind: "mutation" }), method(object({ location: StorageLocationSchema }), number().nullable()), method(BeginUploadInputSchema, BeginUploadResultSchema, { kind: "mutation" }), method(WriteChunkInputSchema, _void(), { kind: "mutation" }), method(FinalizeUploadInputSchema, _void(), { kind: "mutation" }), method(AbortUploadInputSchema, _void(), { kind: "mutation" }), method(BeginDownloadInputSchema, BeginDownloadResultSchema, { kind: "mutation" }), method(ReadChunkInputSchema, _instanceof(Uint8Array)), method(EndDownloadInputSchema, _void(), { kind: "mutation" });
20775
+ /** Profile-exported FormBuilder schema. Shape is ConfigUISchema at the UI. */
20776
+ var ProfileSettingsSchemaBridge = unknown().nullable();
20777
+ var ProfileSettingsBagSchema = record(string(), unknown());
20685
20778
  /**
20686
20779
  * A live terminal session hosted by the provider addon. Output and input do
20687
20780
  * NOT flow through the capability — they use the addon data plane
@@ -20711,7 +20804,14 @@ var TerminalSessionInfoSchema = object({
20711
20804
  var TerminalProfileInfoSchema = object({
20712
20805
  profileId: string(),
20713
20806
  label: string(),
20714
- description: string().optional()
20807
+ description: string().optional(),
20808
+ /** Spawn defaults the instance form copies on create. */
20809
+ executable: string().optional(),
20810
+ args: array(string()).readonly().optional(),
20811
+ cwd: string().optional(),
20812
+ environment: array(string()).readonly().optional(),
20813
+ /** ConfigUISchema for instance knobs, or null when the profile has none. */
20814
+ settingsSchema: ProfileSettingsSchemaBridge.optional()
20715
20815
  });
20716
20816
  /**
20717
20817
  * A durable operator-created Terminal instance. Profiles are templates; only
@@ -20724,7 +20824,12 @@ var TerminalInstanceInfoSchema = object({
20724
20824
  profileId: string(),
20725
20825
  profileLabel: string(),
20726
20826
  name: string(),
20727
- enabled: boolean()
20827
+ enabled: boolean(),
20828
+ executable: string(),
20829
+ args: array(string()).readonly(),
20830
+ cwd: string(),
20831
+ environment: array(string()).readonly(),
20832
+ profileSettings: ProfileSettingsBagSchema
20728
20833
  });
20729
20834
  var TerminalLegacyCameraSchema = object({
20730
20835
  stableId: string(),
@@ -20754,7 +20859,23 @@ var TerminalOutputBatchSchema = object({
20754
20859
  method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
20755
20860
  targetNodeId: string().min(1),
20756
20861
  profileId: string().min(1),
20757
- name: string().trim().min(1).max(160).optional()
20862
+ name: string().trim().min(1).max(160).optional(),
20863
+ executable: string().max(1024).optional(),
20864
+ args: array(string().max(2048)).max(64).optional(),
20865
+ cwd: string().max(1024).optional(),
20866
+ environment: array(string().max(4096)).max(64).optional(),
20867
+ profileSettings: ProfileSettingsBagSchema.optional()
20868
+ }), TerminalInstanceInfoSchema, {
20869
+ kind: "mutation",
20870
+ auth: "admin"
20871
+ }), method(object({
20872
+ instanceId: string().min(1),
20873
+ name: string().trim().min(1).max(160).optional(),
20874
+ executable: string().max(1024).optional(),
20875
+ args: array(string().max(2048)).max(64).optional(),
20876
+ cwd: string().max(1024).optional(),
20877
+ environment: array(string().max(4096)).max(64).optional(),
20878
+ profileSettings: ProfileSettingsBagSchema.optional()
20758
20879
  }), TerminalInstanceInfoSchema, {
20759
20880
  kind: "mutation",
20760
20881
  auth: "admin"
@@ -20776,7 +20897,11 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
20776
20897
  }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
20777
20898
  profileId: string(),
20778
20899
  cols: number().int().positive(),
20779
- rows: number().int().positive()
20900
+ rows: number().int().positive(),
20901
+ executable: string().max(1024).optional(),
20902
+ args: array(string().max(2048)).max(64).optional(),
20903
+ cwd: string().max(1024).optional(),
20904
+ environment: array(string().max(4096)).max(64).optional()
20780
20905
  }), TerminalSessionInfoSchema, {
20781
20906
  kind: "mutation",
20782
20907
  auth: "admin"
@@ -24659,10 +24784,10 @@ var lawnMowerControlCapability = {
24659
24784
  *
24660
24785
  * • The SDK (mobile / web client) consumes `getConnectionEndpoints()`
24661
24786
  * to receive an ordered list of candidate base URLs it should race
24662
- * on connect — LAN IPv4 first (lowest latency when on same network),
24663
- * then public hostname (if a tunnel is up), then IPv6. The SDK can
24664
- * race them with short timeouts and stick with the winner for the
24665
- * session.
24787
+ * on connect — LAN IPv4 and stable LAN IPv6 first (lowest latency
24788
+ * when on the same network), then public hostname (if a tunnel is
24789
+ * up). The SDK can race them with short timeouts and stick with the
24790
+ * winner for the session.
24666
24791
  *
24667
24792
  * Why hub-only: agents are not directly addressable by the operator's
24668
24793
  * clients — they reverse-connect to the hub. Exposing their interfaces
@@ -24817,6 +24942,17 @@ var NotificationEndpointSchema = object({
24817
24942
  /** What the ranking currently resolves to (null when nothing is reachable). */
24818
24943
  resolved: string().nullable()
24819
24944
  });
24945
+ /**
24946
+ * The URLs the SDK / viewer should race for API access. `baseUrls` empty =
24947
+ * AUTO (every LAN IPv4 + the public tunnel). `resolved` is what that choice
24948
+ * currently expands to, so the UI can show the effective set either way.
24949
+ */
24950
+ var ViewerEndpointsSchema = object({
24951
+ /** The operator's explicit race set, or empty for AUTO. */
24952
+ baseUrls: array(string()).readonly(),
24953
+ /** What the ranking currently resolves to (may be empty if nothing is up). */
24954
+ resolved: array(string()).readonly()
24955
+ });
24820
24956
  var AllowedAddressesSchema = object({
24821
24957
  /**
24822
24958
  * Allowlist of interface addresses operators have explicitly opted
@@ -24825,6 +24961,20 @@ var AllowedAddressesSchema = object({
24825
24961
  * Network Addresses admin page and persisted by the addon.
24826
24962
  */
24827
24963
  addresses: array(string()).readonly() });
24964
+ var TlsStatusSchema = object({
24965
+ mode: _enum([
24966
+ "generated",
24967
+ "uploaded",
24968
+ "disabled"
24969
+ ]),
24970
+ leafFingerprintSha256: string().nullable(),
24971
+ caFingerprintSha256: string().nullable(),
24972
+ validTo: string().nullable(),
24973
+ sans: array(string()),
24974
+ caCertPem: string().nullable(),
24975
+ reissueError: string().nullable(),
24976
+ restartRequired: boolean()
24977
+ });
24828
24978
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24829
24979
  /**
24830
24980
  * LEGACY HINT — do not send from new code. Kept optional so clients
@@ -24834,17 +24984,31 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
24834
24984
  */
24835
24985
  port: number().int().min(1).max(65535).optional(),
24836
24986
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24837
- * candidate. Default `true`. */
24987
+ * candidate. Default `false` — loopback is not a client route. */
24838
24988
  includeLoopback: boolean().optional(),
24839
- /** Skip IPv6 entries. Some legacy clients can't parse them.
24840
- * Default `false`. */
24989
+ /** Skip IPv6 entries. Default `false` the palette includes stable
24990
+ * LAN IPv6 (ICE/WebRTC uses dual-stack regardless). Pass `true` to
24991
+ * hide them. The viewer HTTP/WS race is `getViewerEndpoints`. */
24841
24992
  ipv4Only: boolean().optional(),
24842
24993
  /** Scheme to emit for LAN/loopback URLs. Default `'http'`.
24843
24994
  * Pass `'https'` when the caller is itself loaded over HTTPS
24844
24995
  * to avoid mixed-content blocks in the browser. The public
24845
24996
  * tunnel always emits `https://` regardless. */
24846
24997
  scheme: _enum(["http", "https"]).optional()
24847
- }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
24998
+ }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), ViewerEndpointsSchema), method(object({ baseUrls: array(string()).readonly() }), ViewerEndpointsSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" }), method(_void(), TlsStatusSchema), method(object({ reason: string().optional() }), TlsStatusSchema, {
24999
+ kind: "mutation",
25000
+ auth: "admin"
25001
+ }), method(object({
25002
+ certPem: string().min(1),
25003
+ keyPem: string().min(1),
25004
+ caPem: string().optional()
25005
+ }), TlsStatusSchema, {
25006
+ kind: "mutation",
25007
+ auth: "admin"
25008
+ }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
25009
+ kind: "mutation",
25010
+ auth: "admin"
25011
+ });
24848
25012
  var LockControlStatusSchema = object({
24849
25013
  /** Lifecycle state of the lock. `jammed` means the motor reported
24850
25014
  * failure to reach the target — operator intervention required. */
@@ -32871,6 +33035,12 @@ Object.freeze({
32871
33035
  addonId: null,
32872
33036
  access: "create"
32873
33037
  },
33038
+ "localNetwork.downloadCa": {
33039
+ capName: "local-network",
33040
+ capScope: "system",
33041
+ addonId: null,
33042
+ access: "view"
33043
+ },
32874
33044
  "localNetwork.getAllowedAddresses": {
32875
33045
  capName: "local-network",
32876
33046
  capScope: "system",
@@ -32895,18 +33065,42 @@ Object.freeze({
32895
33065
  addonId: null,
32896
33066
  access: "view"
32897
33067
  },
33068
+ "localNetwork.getTlsStatus": {
33069
+ capName: "local-network",
33070
+ capScope: "system",
33071
+ addonId: null,
33072
+ access: "view"
33073
+ },
33074
+ "localNetwork.getViewerEndpoints": {
33075
+ capName: "local-network",
33076
+ capScope: "system",
33077
+ addonId: null,
33078
+ access: "view"
33079
+ },
32898
33080
  "localNetwork.list": {
32899
33081
  capName: "local-network",
32900
33082
  capScope: "system",
32901
33083
  addonId: null,
32902
33084
  access: "view"
32903
33085
  },
33086
+ "localNetwork.regenerateCertificate": {
33087
+ capName: "local-network",
33088
+ capScope: "system",
33089
+ addonId: null,
33090
+ access: "create"
33091
+ },
32904
33092
  "localNetwork.resetAllowlistToBestMatch": {
32905
33093
  capName: "local-network",
32906
33094
  capScope: "system",
32907
33095
  addonId: null,
32908
33096
  access: "delete"
32909
33097
  },
33098
+ "localNetwork.revertToGeneratedCertificate": {
33099
+ capName: "local-network",
33100
+ capScope: "system",
33101
+ addonId: null,
33102
+ access: "create"
33103
+ },
32910
33104
  "localNetwork.setAllowedAddresses": {
32911
33105
  capName: "local-network",
32912
33106
  capScope: "system",
@@ -32919,6 +33113,18 @@ Object.freeze({
32919
33113
  addonId: null,
32920
33114
  access: "create"
32921
33115
  },
33116
+ "localNetwork.setViewerEndpoints": {
33117
+ capName: "local-network",
33118
+ capScope: "system",
33119
+ addonId: null,
33120
+ access: "create"
33121
+ },
33122
+ "localNetwork.uploadCertificate": {
33123
+ capName: "local-network",
33124
+ capScope: "system",
33125
+ addonId: null,
33126
+ access: "create"
33127
+ },
32922
33128
  "lockControl.lock": {
32923
33129
  capName: "lock-control",
32924
33130
  capScope: "device",
@@ -35799,6 +36005,12 @@ Object.freeze({
35799
36005
  addonId: null,
35800
36006
  access: "create"
35801
36007
  },
36008
+ "terminalSession.updateInstance": {
36009
+ capName: "terminal-session",
36010
+ capScope: "system",
36011
+ addonId: null,
36012
+ access: "create"
36013
+ },
35802
36014
  "terminalSession.writeInput": {
35803
36015
  capName: "terminal-session",
35804
36016
  capScope: "system",
@@ -38286,6 +38498,35 @@ Object.freeze(Object.fromEntries([{
38286
38498
  }]
38287
38499
  }].map((s) => [s.stepId, s.defaultModelId])));
38288
38500
  string().min(1);
38501
+ var CLUSTER_STEP_SETTING_FIELDS = [{
38502
+ stepId: "face-embedding",
38503
+ key: "minLandmarkFaceSize",
38504
+ label: "Min face size for recognition (detection px)",
38505
+ description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
38506
+ type: "slider",
38507
+ min: 0,
38508
+ max: 64,
38509
+ step: 2,
38510
+ default: 24
38511
+ }];
38512
+ function clusterStepSettingKey(stepId, fieldKey) {
38513
+ return `clusterStepSetting:${stepId}:${fieldKey}`;
38514
+ }
38515
+ var ClusterSettingNumberSchema = number().finite();
38516
+ function readClusterStepSettings(config) {
38517
+ const out = {};
38518
+ for (const field of CLUSTER_STEP_SETTING_FIELDS) {
38519
+ const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
38520
+ const value = parsed.success ? parsed.data : field.default;
38521
+ const existing = out[field.stepId] ?? {};
38522
+ out[field.stepId] = {
38523
+ ...existing,
38524
+ [field.key]: value
38525
+ };
38526
+ }
38527
+ return out;
38528
+ }
38529
+ readClusterStepSettings({});
38289
38530
  object({
38290
38531
  /**
38291
38532
  * Fraction of the box's own size added on EACH side before cutting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-tuya",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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",