@camstack/addon-provider-wyze 0.2.121 → 0.2.122

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 +238 -188
  2. package/dist/addon.mjs +238 -188
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -31,7 +31,7 @@ let net = require("net");
31
31
  net = __toESM(net, 1);
32
32
  let node_child_process = require("node:child_process");
33
33
  let node_tls = require("node:tls");
34
- //#region ../types/dist/event-category-C5xZWqz6.mjs
34
+ //#region ../types/dist/event-category-BGmz_oxW.mjs
35
35
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
36
36
  EventCategory["SystemBoot"] = "system.boot";
37
37
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -564,6 +564,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
564
564
  */
565
565
  EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
566
566
  EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
567
+ /**
568
+ * A camera provider's RAW onboard motion push (D673): the `MotionOnMotionChanged`
569
+ * data shape without `triggeredBy`, built by `providerMotionEvent`. Its one
570
+ * consumer is the orchestrator's camera-sensors bridge, which re-emits the
571
+ * camera's EFFECTIVE onboard motion as `MotionOnMotionChanged` (a pass-through
572
+ * for a camera with no attached sensor). Providers must not emit
573
+ * `MotionOnMotionChanged` themselves; `scripts/check-onboard-motion-emitters.ts`
574
+ * keeps it so (camera-grid is a named, deferred exception).
575
+ */
567
576
  EventCategory["ProviderMotion"] = "provider.motion";
568
577
  EventCategory["ProviderDetection"] = "provider.detection";
569
578
  EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
@@ -5402,7 +5411,7 @@ var ZodIssueCode = {
5402
5411
  var ZodFirstPartyTypeKind;
5403
5412
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5404
5413
  //#endregion
5405
- //#region ../types/dist/sleep-gU6hqv4_.mjs
5414
+ //#region ../types/dist/sleep-CDCONfkt.mjs
5406
5415
  /**
5407
5416
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5408
5417
  * window to float samples (D455).
@@ -7445,7 +7454,7 @@ function errMsg(err) {
7445
7454
  return String(err);
7446
7455
  }
7447
7456
  //#endregion
7448
- //#region ../types/dist/composition-Dq42XWp0.mjs
7457
+ //#region ../types/dist/composition-43aqsZQj.mjs
7449
7458
  /**
7450
7459
  * The ffmpeg build every CamStack node downloads, per platform and arch.
7451
7460
  *
@@ -7502,13 +7511,12 @@ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac
7502
7511
  * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
7503
7512
  * valutatore puro del suo FRONTE.
7504
7513
  *
7505
- * Viveva dentro il builtin virtual-doorbell
7506
- * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
7507
- * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
7508
- * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
7509
- * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
7510
- * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
7511
- * SPOSTA qui e il doorbell la ri-esporta.
7514
+ * UNA tabella per tutti quelli che chiedono "questo device è ALTO?": il
7515
+ * trigger `RecordingTriggers.sensorDeviceIds` del recorder, i marker dei
7516
+ * sensori della post-analysis, il bridge camera-sensors dell'orchestratore e
7517
+ * le sorgenti di un campanello composto (`doorbellFeatureSources`, D671).
7518
+ * Una seconda copia divergerebbe alla prima cap aggiunta, e il sintomo — "il
7519
+ * sensore fa suonare il campanello ma non registra" — è esattamente D62.
7512
7520
  *
7513
7521
  * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
7514
7522
  * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
@@ -7534,8 +7542,6 @@ var SOURCE_CAP_ACTIVE_FIELD = {
7534
7542
  vibration: "detected",
7535
7543
  tamper: "tampered"
7536
7544
  };
7537
- /** Cap names whose presence in a device's bindings qualify it as a source. */
7538
- var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
7539
7545
  /**
7540
7546
  * Error types for the safe expression engine. Two distinct classes so callers
7541
7547
  * can tell a compile-time (grammar) failure from a runtime (evaluation)
@@ -9265,7 +9271,7 @@ var RecordingTriggersSchema = object({
9265
9271
  *
9266
9272
  * Cosa vuol dire "alto" dipende dal TIPO di device e non è deciso qui:
9267
9273
  * `SOURCE_CAP_ACTIVE_FIELD` (`catalogs/sensor-active-state.ts`) è LA tabella,
9268
- * la stessa che il virtual-doorbell usa dal 2026-08-05. Ed è il FRONTE, non
9274
+ * la stessa del campanello composto (D671). Ed è il FRONTE, non
9269
9275
  * il livello: un contatto trovato già aperto al riavvio del runner non fa
9270
9276
  * registrare.
9271
9277
  */
@@ -15658,109 +15664,10 @@ method(object({
15658
15664
  deviceIds: array(number()).readonly(),
15659
15665
  caps: array(string()).readonly().optional()
15660
15666
  }), record(string(), record(string(), unknown().nullable())));
15661
- /**
15662
- * Doorbell button cap. Two kinds of providers coexist behind this cap
15663
- * name (same pattern as `snapshot`):
15664
- *
15665
- * - **Native** providers: registered per-device by device-driver
15666
- * addons via `ctx.registerNativeCap` — either on a
15667
- * `DeviceType.Button` accessory with `role: DeviceRole.Doorbell`,
15668
- * or directly on the camera (Reolink registers at camera level).
15669
- * Emits an `onPressed` event every time the firmware pushes a
15670
- * ring; status tracks the last press and a pressCount since start
15671
- * (diagnostic).
15672
- *
15673
- * - **Wrapper** provider: the `virtual-doorbell` system builtin
15674
- * (`@camstack/system/builtins/doorbell`). Turns ANY binary-ish
15675
- * device (contact / switch / event-emitter …) into a doorbell for
15676
- * a camera. `defaultActive: false` — the operator explicitly binds
15677
- * it per camera in the device-bindings UI, then picks the source
15678
- * device + trigger in the per-device settings.
15679
- *
15680
- * The DeviceEventPropagator re-emits `onPressed` on the camera parent
15681
- * — subscribers listening at the camera level receive ring events
15682
- * with `via[]` populated. No code on the parent needed.
15683
- */
15684
- var DoorbellStatusSchema = object({
15685
- /** Ms epoch of the last press. null = never observed since this provider started. */
15686
- lastPressedAt: number().nullable(),
15687
- /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
15688
- pressCountSinceStart: number()
15689
- });
15690
- var DoorbellPressEventSchema = object({
15691
- deviceId: number(),
15692
- timestamp: number()
15693
- });
15694
- /**
15695
- * The `doorbell-source` extension slot's editable value (D648): the camera's
15696
- * virtual-doorbell trigger devices, exactly what the wrapper's
15697
- * `doorbellSources` persists (as `{ deviceId }` records).
15698
- */
15699
- var DoorbellSourceSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
15700
- /**
15701
- * The `doorbell-source` slot — which devices ring this camera's VIRTUAL
15702
- * doorbell, owned by the `virtual-doorbell` wrapper over its
15703
- * `doorbellSources` store (D648). A source rings when one of its
15704
- * `SOURCE_CAPS` rises. Whether the wrapper is bound to the camera at all is a
15705
- * different authority (`deviceManager.setWrapperActive`, D62) — the slot
15706
- * reports it, it never writes it.
15707
- */
15708
- var DOORBELL_SOURCE_SLOT = {
15709
- id: "doorbell-source",
15710
- label: "Doorbell trigger devices",
15711
- description: "Any of these becoming active rings this camera's virtual doorbell: a binary sensor when it goes active, a switch when it turns on.",
15712
- shape: "association",
15713
- hostDeviceTypes: [DeviceType.Camera],
15714
- sourceCaps: SOURCE_CAPS,
15715
- cardinality: "many",
15716
- value: DoorbellSourceSlotValueSchema
15717
- };
15718
- var doorbellCapability = {
15719
- name: "doorbell",
15720
- scope: "device",
15721
- deviceNative: true,
15722
- mode: "singleton",
15723
- kind: "wrapper",
15724
- defaultActive: false,
15725
- deviceTypes: [DeviceType.Button, DeviceType.Camera],
15726
- extensionSlots: [DOORBELL_SOURCE_SLOT],
15727
- methods: {},
15728
- events: {
15729
- /**
15730
- * Fires once per physical press. Reolink delivers via Baichuan
15731
- * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
15732
- * release/duration — it's a pulse.
15733
- */
15734
- onPressed: {
15735
- data: DoorbellPressEventSchema,
15736
- fromState: {
15737
- field: "lastPressedAt",
15738
- on: "advance"
15739
- }
15740
- } },
15741
- status: {
15742
- schema: DoorbellStatusSchema,
15743
- kind: "push"
15744
- },
15745
- /**
15746
- * Runtime-state slice — last press timestamp + lifetime press count.
15747
- * Mirrored by the kernel and readable via
15748
- * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
15749
- * without subscribing.
15750
- */
15751
- runtimeState: DoorbellStatusSchema,
15752
- /**
15753
- * Runtime-state durability: **restored** — a monotonic accumulator: the slice IS the record. `lastPressedAt` is content here, not a clock, so it is deliberately NOT volatile.
15754
- *
15755
- * See `RuntimeStateDurability`. Enforced by
15756
- * `scripts/check-runtime-state-durability.ts`.
15757
- */
15758
- durability: "restored"
15759
- };
15760
- var ExtensionSlotIdSchema = _enum(["doorbell-source", "linked-devices"]);
15761
- DeviceType.Camera, DeviceType.Camera;
15667
+ var ExtensionSlotIdSchema = _enum(["linked-devices"]);
15668
+ DeviceType.Camera;
15762
15669
  /** A slot's editable value, discriminated on `slot`. */
15763
- var ExtensionSlotValueSchema = discriminatedUnion("slot", [DoorbellSourceSlotValueSchema.extend({ slot: literal("doorbell-source") }), LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
15670
+ var ExtensionSlotValueSchema = discriminatedUnion("slot", [LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
15764
15671
  DeviceType.Camera;
15765
15672
  var ExtensionSourceAvailabilitySchema = discriminatedUnion("state", [object({ state: literal("ok") }), object({
15766
15673
  state: literal("unreachable"),
@@ -15797,10 +15704,11 @@ var ExtensionSlotSourceSchema = object({
15797
15704
  });
15798
15705
  /**
15799
15706
  * A DIFFERENT authority that gates whether the slot's sources do anything —
15800
- * reported read-only, named, never written through this contract (D62). The
15801
- * virtual doorbell's sources ring only while the wrapper is bound to the
15802
- * camera, and that binding belongs to `deviceManager.setWrapperActive`.
15803
- * `unknown` means the gate could not be read; it is not "inactive" (D315).
15707
+ * reported read-only, named, never written through this contract (D62) — e.g.
15708
+ * a slot whose sources act only while a wrapper is bound to the host, a
15709
+ * binding that belongs to `deviceManager.setWrapperActive`. No slot reports
15710
+ * one today (`linked-devices` answers `null`). `unknown` means the gate could
15711
+ * not be read; it is not "inactive" (D315).
15804
15712
  */
15805
15713
  var ExtensionSlotActivationSchema = object({
15806
15714
  state: _enum([
@@ -15816,7 +15724,7 @@ var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
15816
15724
  /** `<source>:<slotId>` — the routing key for `listCandidates` / `setSlot`. */
15817
15725
  slotRef: string(),
15818
15726
  slot: ExtensionSlotIdSchema,
15819
- /** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
15727
+ /** Names the store written (D62), e.g. `device-manager:_linkedDeviceIds`. */
15820
15728
  authority: string(),
15821
15729
  activation: ExtensionSlotActivationSchema.nullable(),
15822
15730
  value: ExtensionSlotValueSchema,
@@ -20938,12 +20846,26 @@ var CameraSensorRefSchema = object({
20938
20846
  function cameraSensorRefKey(ref) {
20939
20847
  return `${ref.addonId}/${ref.stableId}`;
20940
20848
  }
20941
- /** CS-1 accepts exactly one mode; CS-2 (D673) widens it to confirm / replace. */
20942
- var CameraSensorsModeSchema = _enum(["in-addition"]);
20943
- var CameraSensorsConfigSchema = object({
20944
- mode: CameraSensorsModeSchema,
20945
- sensors: array(CameraSensorRefSchema).min(1).max(8)
20946
- }).superRefine((value, ctx) => {
20849
+ /**
20850
+ * A sensor's mode (D673). `in-addition`: its motion is the camera's motion too.
20851
+ * `replace`: its motion is the camera's, and while one is READY the camera's
20852
+ * own motion no longer counts on its own. `confirm`: while one is READY the
20853
+ * camera's own motion counts only while a confirm sensor is active — with or
20854
+ * without a replace sensor (spec C2, the controller's ruling on Q1).
20855
+ */
20856
+ var CameraSensorModeSchema = _enum([
20857
+ "in-addition",
20858
+ "confirm",
20859
+ "replace"
20860
+ ]);
20861
+ /** A stored sensor with no mode — every CS-1 row — is in addition. */
20862
+ var DEFAULT_CAMERA_SENSOR_MODE = "in-addition";
20863
+ /**
20864
+ * A camera's attached sensors. A CS-1 row carried an envelope
20865
+ * `mode: 'in-addition'`: `z.object` drops it on parse, and the PARSED value is
20866
+ * what the write stores (D672 rule 3), so it is never written again.
20867
+ */
20868
+ var CameraSensorsConfigSchema = object({ sensors: array(CameraSensorRefSchema.extend({ mode: CameraSensorModeSchema.default(DEFAULT_CAMERA_SENSOR_MODE) })).min(1).max(8) }).superRefine((value, ctx) => {
20947
20869
  const seen = /* @__PURE__ */ new Set();
20948
20870
  value.sensors.forEach((ref, index) => {
20949
20871
  const key = cameraSensorRefKey(ref);
@@ -20955,39 +20877,41 @@ var CameraSensorsConfigSchema = object({
20955
20877
  seen.add(key);
20956
20878
  });
20957
20879
  });
20958
- var RefFields = {
20880
+ /** Every attachment names its mode; an older orchestrator that does not is in addition. */
20881
+ var AttachmentFields = {
20959
20882
  addonId: string(),
20960
- stableId: string()
20883
+ stableId: string(),
20884
+ mode: CameraSensorModeSchema.default(DEFAULT_CAMERA_SENSOR_MODE)
20961
20885
  };
20962
20886
  var CameraSensorAttachmentSchema = discriminatedUnion("state", [
20963
20887
  object({
20964
- ...RefFields,
20888
+ ...AttachmentFields,
20965
20889
  state: literal("ready"),
20966
20890
  deviceId: number(),
20967
20891
  name: string(),
20968
20892
  caps: array(string()).readonly()
20969
20893
  }),
20970
20894
  object({
20971
- ...RefFields,
20895
+ ...AttachmentFields,
20972
20896
  state: literal("offline"),
20973
20897
  deviceId: number(),
20974
20898
  name: string(),
20975
20899
  reason: _enum(["offline", "disabled"])
20976
20900
  }),
20977
20901
  object({
20978
- ...RefFields,
20902
+ ...AttachmentFields,
20979
20903
  state: literal("missing"),
20980
20904
  reason: string()
20981
20905
  }),
20982
20906
  object({
20983
- ...RefFields,
20907
+ ...AttachmentFields,
20984
20908
  state: literal("unsupported"),
20985
20909
  deviceId: number(),
20986
20910
  name: string(),
20987
20911
  reason: string()
20988
20912
  }),
20989
20913
  object({
20990
- ...RefFields,
20914
+ ...AttachmentFields,
20991
20915
  state: literal("unreadable"),
20992
20916
  reason: string()
20993
20917
  })
@@ -21000,17 +20924,49 @@ var CameraSensorAttachmentSchema = discriminatedUnion("state", [
21000
20924
  * say.
21001
20925
  */
21002
20926
  var CameraSensorsNotForwardedSchema = _enum(["onboard-not-listed", "no-active-detection"]);
20927
+ /** Whether the camera's OWN motion counts on its own (D673 C2), from its READY sensors (C3). */
20928
+ var CameraOwnMotionSchema = _enum([
20929
+ "always",
20930
+ "when-confirmed",
20931
+ "never"
20932
+ ]);
20933
+ /** Confirm / Replace cannot suppress this camera's motion: its provider still emits it directly (D673 C13). */
20934
+ var CameraSensorsNotEnforcedSchema = _enum(["provider-not-split"]);
20935
+ /**
20936
+ * The camera's OWN onboard motion (D696): the raw input the bridge combines,
20937
+ * never the effective value. The camera's `motion` slice is the effective one,
20938
+ * so a view of the camera's own row reads this.
20939
+ * - `atMs`: when the bridge received the camera's last own edge.
20940
+ * - `detected`: evaluated WHEN THE STATUS IS ASKED — the last edge said `true`
20941
+ * AND it is younger than the window the bridge counts the camera's own motion
20942
+ * for (`motionCooldownMs`, floored at two sustain ticks). A camera that never
20943
+ * sends a clear (heartbeats only) therefore reads `false` once no push came
20944
+ * within the window; a status read is a snapshot, so a live view re-asks.
20945
+ * Absent = this orchestrator has not received the camera's own motion since it
20946
+ * started (unknown, never "quiet"), or an orchestrator older than D696.
20947
+ */
20948
+ var CameraRawLevelSchema = object({
20949
+ detected: boolean(),
20950
+ atMs: number()
20951
+ });
21003
20952
  var CameraSensorsStatusSchema = discriminatedUnion("state", [
21004
- object({ state: literal("not-configured") }),
20953
+ object({
20954
+ state: literal("not-configured"),
20955
+ cameraLevel: CameraRawLevelSchema.optional()
20956
+ }),
21005
20957
  object({
21006
20958
  state: literal("unreadable"),
21007
- reason: string()
20959
+ reason: string(),
20960
+ cameraLevel: CameraRawLevelSchema.optional()
21008
20961
  }),
21009
20962
  object({
21010
20963
  state: literal("configured"),
21011
- mode: CameraSensorsModeSchema,
20964
+ cameraLevel: CameraRawLevelSchema.optional(),
21012
20965
  sensors: array(CameraSensorAttachmentSchema).readonly(),
21013
- notForwarded: CameraSensorsNotForwardedSchema.optional()
20966
+ /** Absent = an orchestrator older than CS-2: it does not say (D315). */
20967
+ cameraMotion: CameraOwnMotionSchema.optional(),
20968
+ notForwarded: CameraSensorsNotForwardedSchema.optional(),
20969
+ notEnforced: CameraSensorsNotEnforcedSchema.optional()
21014
20970
  })
21015
20971
  ]);
21016
20972
  Object.freeze({
@@ -24241,8 +24197,10 @@ var RunnerCameraConfigSchema = object({
24241
24197
  * - `analyzer`: ML/frame-diff motion detection (motion-wasm or other
24242
24198
  * analyzer providers) running on decoded frames in this runner.
24243
24199
  * - `onboard`: camera-native motion pushes (Reolink Baichuan, ONVIF
24244
- * analytics). The provider emits ProviderMotion
24245
- * events; the orchestrator forwards to `reportMotion`.
24200
+ * analytics). The provider emits its raw push as `ProviderMotion`
24201
+ * (D673); the orchestrator's camera-sensors bridge re-emits the camera's
24202
+ * effective motion as `MotionOnMotionChanged`, which is what reaches
24203
+ * `reportMotion`.
24246
24204
  */
24247
24205
  motionSources: MotionSourcesSchema.default(["analyzer"]),
24248
24206
  /**
@@ -24419,7 +24377,15 @@ var RunnerLocalLoadSchema = object({
24419
24377
  hasGpu: boolean(),
24420
24378
  inferenceBackend: string().optional(),
24421
24379
  cpuCores: number().optional()
24422
- })
24380
+ }),
24381
+ /**
24382
+ * Who writes a camera's `motion` runtime slice (D696). `'orchestrator'`: this
24383
+ * runner never writes it. ABSENT = a runner older than D696, which still
24384
+ * writes the slice from its phase machine; the orchestrator does not write
24385
+ * while any runner node is absent-or-unanswered, so one slice never has two
24386
+ * writers.
24387
+ */
24388
+ motionSliceWriter: literal("orchestrator").optional()
24423
24389
  });
24424
24390
  /**
24425
24391
  * Aggregate runtime metrics for the runner's whole local pool. Mirrors the
@@ -30052,6 +30018,70 @@ onStatusChanged: { data: object({
30052
30018
  * whether persisting is worth a SQLite commit. */
30053
30019
  volatileStateFields: ["lastChangedAt"]
30054
30020
  };
30021
+ var DoorbellStatusSchema = object({
30022
+ /** Ms epoch of the last press. null = never observed since this provider started. */
30023
+ lastPressedAt: number().nullable(),
30024
+ /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
30025
+ pressCountSinceStart: number()
30026
+ });
30027
+ var DoorbellPressEventSchema = object({
30028
+ deviceId: number(),
30029
+ timestamp: number()
30030
+ });
30031
+ /**
30032
+ * Doorbell button cap. Two kinds of provider serve it, and to every consumer
30033
+ * they are the same cap:
30034
+ *
30035
+ * - **Native** providers, registered per device by a driver addon through
30036
+ * `ctx.registerNativeCap` — a `DeviceType.Button` accessory with
30037
+ * `role: DeviceRole.Doorbell`, or the camera itself (Reolink registers at
30038
+ * camera level). They emit `onPressed` on every firmware ring.
30039
+ * - **A composed doorbell** (D670, D671): a camera's customization gives it
30040
+ * the cap, its `lastPressedAt` rising from the sources the operator
30041
+ * assigned in Customize → Doorbell, and the composer emits `onPressed`
30042
+ * from that field's advance (`fromState`).
30043
+ *
30044
+ * The DeviceEventPropagator re-emits `onPressed` on the camera parent.
30045
+ */
30046
+ var doorbellCapability = {
30047
+ name: "doorbell",
30048
+ scope: "device",
30049
+ deviceNative: true,
30050
+ mode: "singleton",
30051
+ deviceTypes: [DeviceType.Button, DeviceType.Camera],
30052
+ methods: {},
30053
+ events: {
30054
+ /**
30055
+ * Fires once per physical press. Reolink delivers via Baichuan
30056
+ * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
30057
+ * release/duration — it's a pulse.
30058
+ */
30059
+ onPressed: {
30060
+ data: DoorbellPressEventSchema,
30061
+ fromState: {
30062
+ field: "lastPressedAt",
30063
+ on: "advance"
30064
+ }
30065
+ } },
30066
+ status: {
30067
+ schema: DoorbellStatusSchema,
30068
+ kind: "push"
30069
+ },
30070
+ /**
30071
+ * Runtime-state slice — last press timestamp + lifetime press count.
30072
+ * Mirrored by the kernel and readable via
30073
+ * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
30074
+ * without subscribing.
30075
+ */
30076
+ runtimeState: DoorbellStatusSchema,
30077
+ /**
30078
+ * Runtime-state durability: **restored** — a monotonic accumulator: the slice IS the record. `lastPressedAt` is content here, not a clock, so it is deliberately NOT volatile.
30079
+ *
30080
+ * See `RuntimeStateDurability`. Enforced by
30081
+ * `scripts/check-runtime-state-durability.ts`.
30082
+ */
30083
+ durability: "restored"
30084
+ };
30055
30085
  /**
30056
30086
  * Enum-state sensor — a string value picked from a finite option set.
30057
30087
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -32196,9 +32226,9 @@ var MotionStatusSchema = object({
32196
32226
  /** Ms epoch of the last detected-true observation. Null if never detected. */
32197
32227
  lastDetectedAt: number().nullable(),
32198
32228
  /**
32199
- * `MOTION_CLOSE_AFTER_MS` while `detected: true` on a `Camera` device,
32200
- * `null` while false and on every `Sensor` device (D475) — see that
32201
- * constant's doc for the one-authority rule.
32229
+ * `MOTION_CLOSE_AFTER_MS` while `detected: true` on a `Camera` device (the
32230
+ * orchestrator writes it, D696), `null` while false and on every `Sensor`
32231
+ * device (D475) — see that constant's doc for the one-authority rule.
32202
32232
  *
32203
32233
  * ## Reading this field still arms nothing
32204
32234
  *
@@ -32216,34 +32246,31 @@ var MotionStatusSchema = object({
32216
32246
  * `addon-export-alexa/src/motion-clear-hold.ts` and
32217
32247
  * `addon-export-hap`'s `RESET_DEBOUNCE_MS` both do.
32218
32248
  *
32219
- * ## Who writes it
32220
- *
32221
- * - **Cameras** — the runner's phase machine, `active → watching` on
32222
- * `cooldown_expired`, which then writes this slice with
32223
- * `detected: false` (`handlePhaseChanged` in
32224
- * `pipeline-runner/index.ts`). It produces the FALLING edge, which
32225
- * matters most for the sources that only ever push a rising one:
32226
- * Reolink emits `MotionOnMotionChanged { detected: true }` and never
32227
- * a false.
32228
- * - **Sensors** (Home Assistant binary sensors, Homematic) — the
32229
- * provider pushes the false itself, from the upstream system's own
32230
- * state change. No phase machine is involved.
32231
- *
32232
- * ### The phase machine is CANONICAL, not sole — and that is a defect
32233
- *
32234
- * An earlier revision of this docblock (mine, 2026-09-12) claimed the
32235
- * phase machine is the sole writer for a camera. It is not.
32236
- * `hikvision-camera.ts:3464` and `amcrest-camera.ts:445` both call
32237
- * `setCapSlice(motionCapability, …)` on their own rising edge, and
32238
- * Hikvision's comment says why: it read THIS docblock, agreed the
32239
- * runner is canonical, and wrote anyway to avoid per-tick churn. So
32240
- * two authorities can disagree about one slice, which this repo
32241
- * forbids, and the doc said otherwise — which is worse than saying
32242
- * nothing, because it reads as verification.
32243
- *
32244
- * This predates D475 and is not fixed there: the fix touches every
32245
- * camera provider. Recorded in D475's Consequences. Do not restore the
32246
- * "sole writer" wording without also removing the other writers.
32249
+ * ## Who writes it (D696)
32250
+ *
32251
+ * - **Cameras** — the orchestrator, and nothing else. It writes the camera's
32252
+ * EFFECTIVE motion (`addon-pipeline-orchestrator`
32253
+ * `camera-sensors/motion-slice.ts`), alongside the effective
32254
+ * `MotionOnMotionChanged` it emits or receives: the camera-sensors bridge
32255
+ * for a split camera, an unsplit emitter's direct event, the analyzer and
32256
+ * device activity, as the camera's `motionSources` counts them. A source
32257
+ * that never sends a clear falls one window after its last `true`.
32258
+ * `lastDetectedAt` moves only on an effective rise. No camera provider
32259
+ * writes this slice (not even a boot seed), and neither does the runner's
32260
+ * phase machine: its phase is "a session is attached" for every on-motion
32261
+ * session, not motion, and it now has its own `DetectionPhaseTransition`.
32262
+ * A provider's `isDetected` answers from the hub's slice.
32263
+ * - **Sensors** (Home Assistant binary sensors, Homematic, Matter, Alexa
32264
+ * imports) — the provider pushes both edges itself, from the upstream
32265
+ * system's own state change. That is the sensor's own fact.
32266
+ *
32267
+ * `scripts/check-onboard-motion-emitters.ts` refuses any other writer of a
32268
+ * `motion` slice; each sensor writer is a named exemption.
32269
+ *
32270
+ * This replaces the D475 §4 rule ("the phase machine is the sole writer").
32271
+ * That rule was never true: Hikvision and Amcrest wrote the slice raw beside
32272
+ * it, every provider seeded it at boot, and every session left it
32273
+ * `detected: true` after its detach.
32247
32274
  */
32248
32275
  autoClearAfterMs: number().nullable()
32249
32276
  });
@@ -32315,11 +32342,12 @@ onMotionChanged: { data: MotionOnMotionChangedDataSchema } },
32315
32342
  */
32316
32343
  runtimeState: MotionStatusSchema,
32317
32344
  /**
32318
- * Runtime-state durability: **session** — every writer of this slice
32345
+ * Runtime-state durability: **session** — the writer of this slice
32319
32346
  * writes only on an EDGE, so a restored `detected: true` would stay
32320
32347
  * frozen until the next one instead of being corrected. The next edge
32321
- * re-publishes the real state. (On who the writers are, and why there
32322
- * is more than one, see `autoClearAfterMs` above.)
32348
+ * re-publishes the real state, and the orchestrator re-publishes every
32349
+ * camera when it starts writing (D696; on who writes it, see
32350
+ * `autoClearAfterMs` above).
32323
32351
  *
32324
32352
  * See `RuntimeStateDurability`. Enforced by
32325
32353
  * `scripts/check-runtime-state-durability.ts`.
@@ -34702,8 +34730,7 @@ var ptzAutotrackCapability = {
34702
34730
  deviceNative: true,
34703
34731
  mode: "singleton",
34704
34732
  /**
34705
- * Two kinds of provider behind one name, the same shape `snapshot` and
34706
- * `doorbell` use:
34733
+ * Two kinds of provider behind one name, the same shape `snapshot` uses:
34707
34734
  *
34708
34735
  * - **Native**: the camera's own firmware autotrack, registered per device by
34709
34736
  * the driver when the probe says it exists (Reolink: yes; Hikvision:
@@ -34711,7 +34738,7 @@ var ptzAutotrackCapability = {
34711
34738
  * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
34712
34739
  * which follows a subject with `ptz.move` pulses driven by the detection
34713
34740
  * pipeline. `defaultActive: false` — the operator binds it per camera in
34714
- * the bindings UI, exactly as `virtual-doorbell` is bound.
34741
+ * the bindings UI.
34715
34742
  *
34716
34743
  * The binding chooses WHICH implementation answers; `setEnabled` stays the
34717
34744
  * one switch for the function either way. A camera never has both driving its
@@ -51669,6 +51696,25 @@ TimelapseRuleInputSchema.extend({
51669
51696
  createdAt: number(),
51670
51697
  updatedAt: number()
51671
51698
  });
51699
+ async function readHubMotionDetected(deviceState, deviceId) {
51700
+ let slice;
51701
+ try {
51702
+ slice = await deviceState.getCapSlice.query({
51703
+ deviceId,
51704
+ capName: "motion"
51705
+ });
51706
+ } catch (err) {
51707
+ const reason = err instanceof Error ? err.message : String(err);
51708
+ throw new Error(`motion state of device ${deviceId} is unreadable: ${reason}`, { cause: err });
51709
+ }
51710
+ const detected = slice?.["detected"];
51711
+ if (typeof detected !== "boolean") throw new Error(`motion state of device ${deviceId} is not published yet (unknown)`);
51712
+ return detected;
51713
+ }
51714
+ MotionOnMotionChangedDataSchema.omit({ triggeredBy: true });
51715
+ function providerMotionEvent(source, data) {
51716
+ return createEvent(EventCategory.ProviderMotion, source, data);
51717
+ }
51672
51718
  object({
51673
51719
  /**
51674
51720
  * Fraction of the box's own size added on EACH side before cutting.
@@ -56137,16 +56183,18 @@ var WyzeCamera = class extends BaseDevice {
56137
56183
  return null;
56138
56184
  }
56139
56185
  }
56186
+ /**
56187
+ * The native `motion` cap. This provider writes NO `motion` slice (D696): the
56188
+ * orchestrator is its only writer, with the EFFECTIVE motion, on the hub.
56189
+ * `isDetected` answers from the HUB's slice; an unreadable hub, or a slice
56190
+ * nobody has published yet, fails the call — never `false` for unknown
56191
+ * (D49, D393).
56192
+ */
56140
56193
  registerMotionProvider() {
56141
56194
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
56142
- if (deviceId !== this.id) return false;
56143
- return this.state.motion.detected ?? false;
56195
+ if (deviceId !== this.id) throw new Error(`WyzeCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
56196
+ return readHubMotionDetected(this.ctx.api.deviceState, this.id);
56144
56197
  } });
56145
- this.setCapSlice(motionCapability, {
56146
- detected: false,
56147
- lastDetectedAt: null,
56148
- autoClearAfterMs: null
56149
- });
56150
56198
  }
56151
56199
  /**
56152
56200
  * Start the configured motion source. `cloud` polls the Wyze events API
@@ -56190,12 +56238,14 @@ var WyzeCamera = class extends BaseDevice {
56190
56238
  }
56191
56239
  }
56192
56240
  /**
56193
- * Emit `MotionOnMotionChanged` (`source: 'onboard'`). The pipeline-runner
56194
- * subscribes and is the sole writer of the `motion` runtime-state slice
56195
- * — symmetric with the Reolink onboard-motion path.
56241
+ * Emit the camera's RAW motion push as `ProviderMotion`
56242
+ * (`source: 'onboard'`, D673). The orchestrator's camera-sensors bridge
56243
+ * re-emits it as the effective `MotionOnMotionChanged`, and the
56244
+ * orchestrator is the only writer of the camera's `motion` runtime-state
56245
+ * slice (D696) — symmetric with the Reolink onboard-motion path.
56196
56246
  */
56197
56247
  emitMotion(timestamp) {
56198
- this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
56248
+ this.ctx.eventBus.emit(providerMotionEvent({
56199
56249
  type: "device",
56200
56250
  id: this.id,
56201
56251
  addonId: WYZE_ADDON_ID,
package/dist/addon.mjs CHANGED
@@ -10,7 +10,7 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls";
10
10
  //#region \0rolldown/runtime.js
11
11
  var __require$1 = /* @__PURE__ */ createRequire(import.meta.url);
12
12
  //#endregion
13
- //#region ../types/dist/event-category-C5xZWqz6.mjs
13
+ //#region ../types/dist/event-category-BGmz_oxW.mjs
14
14
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
15
15
  EventCategory["SystemBoot"] = "system.boot";
16
16
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -543,6 +543,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
543
543
  */
544
544
  EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
545
545
  EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
546
+ /**
547
+ * A camera provider's RAW onboard motion push (D673): the `MotionOnMotionChanged`
548
+ * data shape without `triggeredBy`, built by `providerMotionEvent`. Its one
549
+ * consumer is the orchestrator's camera-sensors bridge, which re-emits the
550
+ * camera's EFFECTIVE onboard motion as `MotionOnMotionChanged` (a pass-through
551
+ * for a camera with no attached sensor). Providers must not emit
552
+ * `MotionOnMotionChanged` themselves; `scripts/check-onboard-motion-emitters.ts`
553
+ * keeps it so (camera-grid is a named, deferred exception).
554
+ */
546
555
  EventCategory["ProviderMotion"] = "provider.motion";
547
556
  EventCategory["ProviderDetection"] = "provider.detection";
548
557
  EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
@@ -5381,7 +5390,7 @@ var ZodIssueCode = {
5381
5390
  var ZodFirstPartyTypeKind;
5382
5391
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5383
5392
  //#endregion
5384
- //#region ../types/dist/sleep-gU6hqv4_.mjs
5393
+ //#region ../types/dist/sleep-CDCONfkt.mjs
5385
5394
  /**
5386
5395
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5387
5396
  * window to float samples (D455).
@@ -7424,7 +7433,7 @@ function errMsg(err) {
7424
7433
  return String(err);
7425
7434
  }
7426
7435
  //#endregion
7427
- //#region ../types/dist/composition-Dq42XWp0.mjs
7436
+ //#region ../types/dist/composition-43aqsZQj.mjs
7428
7437
  /**
7429
7438
  * The ffmpeg build every CamStack node downloads, per platform and arch.
7430
7439
  *
@@ -7481,13 +7490,12 @@ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac
7481
7490
  * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
7482
7491
  * valutatore puro del suo FRONTE.
7483
7492
  *
7484
- * Viveva dentro il builtin virtual-doorbell
7485
- * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
7486
- * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
7487
- * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
7488
- * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
7489
- * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
7490
- * SPOSTA qui e il doorbell la ri-esporta.
7493
+ * UNA tabella per tutti quelli che chiedono "questo device è ALTO?": il
7494
+ * trigger `RecordingTriggers.sensorDeviceIds` del recorder, i marker dei
7495
+ * sensori della post-analysis, il bridge camera-sensors dell'orchestratore e
7496
+ * le sorgenti di un campanello composto (`doorbellFeatureSources`, D671).
7497
+ * Una seconda copia divergerebbe alla prima cap aggiunta, e il sintomo — "il
7498
+ * sensore fa suonare il campanello ma non registra" — è esattamente D62.
7491
7499
  *
7492
7500
  * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
7493
7501
  * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
@@ -7513,8 +7521,6 @@ var SOURCE_CAP_ACTIVE_FIELD = {
7513
7521
  vibration: "detected",
7514
7522
  tamper: "tampered"
7515
7523
  };
7516
- /** Cap names whose presence in a device's bindings qualify it as a source. */
7517
- var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
7518
7524
  /**
7519
7525
  * Error types for the safe expression engine. Two distinct classes so callers
7520
7526
  * can tell a compile-time (grammar) failure from a runtime (evaluation)
@@ -9244,7 +9250,7 @@ var RecordingTriggersSchema = object({
9244
9250
  *
9245
9251
  * Cosa vuol dire "alto" dipende dal TIPO di device e non è deciso qui:
9246
9252
  * `SOURCE_CAP_ACTIVE_FIELD` (`catalogs/sensor-active-state.ts`) è LA tabella,
9247
- * la stessa che il virtual-doorbell usa dal 2026-08-05. Ed è il FRONTE, non
9253
+ * la stessa del campanello composto (D671). Ed è il FRONTE, non
9248
9254
  * il livello: un contatto trovato già aperto al riavvio del runner non fa
9249
9255
  * registrare.
9250
9256
  */
@@ -15637,109 +15643,10 @@ method(object({
15637
15643
  deviceIds: array(number()).readonly(),
15638
15644
  caps: array(string()).readonly().optional()
15639
15645
  }), record(string(), record(string(), unknown().nullable())));
15640
- /**
15641
- * Doorbell button cap. Two kinds of providers coexist behind this cap
15642
- * name (same pattern as `snapshot`):
15643
- *
15644
- * - **Native** providers: registered per-device by device-driver
15645
- * addons via `ctx.registerNativeCap` — either on a
15646
- * `DeviceType.Button` accessory with `role: DeviceRole.Doorbell`,
15647
- * or directly on the camera (Reolink registers at camera level).
15648
- * Emits an `onPressed` event every time the firmware pushes a
15649
- * ring; status tracks the last press and a pressCount since start
15650
- * (diagnostic).
15651
- *
15652
- * - **Wrapper** provider: the `virtual-doorbell` system builtin
15653
- * (`@camstack/system/builtins/doorbell`). Turns ANY binary-ish
15654
- * device (contact / switch / event-emitter …) into a doorbell for
15655
- * a camera. `defaultActive: false` — the operator explicitly binds
15656
- * it per camera in the device-bindings UI, then picks the source
15657
- * device + trigger in the per-device settings.
15658
- *
15659
- * The DeviceEventPropagator re-emits `onPressed` on the camera parent
15660
- * — subscribers listening at the camera level receive ring events
15661
- * with `via[]` populated. No code on the parent needed.
15662
- */
15663
- var DoorbellStatusSchema = object({
15664
- /** Ms epoch of the last press. null = never observed since this provider started. */
15665
- lastPressedAt: number().nullable(),
15666
- /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
15667
- pressCountSinceStart: number()
15668
- });
15669
- var DoorbellPressEventSchema = object({
15670
- deviceId: number(),
15671
- timestamp: number()
15672
- });
15673
- /**
15674
- * The `doorbell-source` extension slot's editable value (D648): the camera's
15675
- * virtual-doorbell trigger devices, exactly what the wrapper's
15676
- * `doorbellSources` persists (as `{ deviceId }` records).
15677
- */
15678
- var DoorbellSourceSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
15679
- /**
15680
- * The `doorbell-source` slot — which devices ring this camera's VIRTUAL
15681
- * doorbell, owned by the `virtual-doorbell` wrapper over its
15682
- * `doorbellSources` store (D648). A source rings when one of its
15683
- * `SOURCE_CAPS` rises. Whether the wrapper is bound to the camera at all is a
15684
- * different authority (`deviceManager.setWrapperActive`, D62) — the slot
15685
- * reports it, it never writes it.
15686
- */
15687
- var DOORBELL_SOURCE_SLOT = {
15688
- id: "doorbell-source",
15689
- label: "Doorbell trigger devices",
15690
- description: "Any of these becoming active rings this camera's virtual doorbell: a binary sensor when it goes active, a switch when it turns on.",
15691
- shape: "association",
15692
- hostDeviceTypes: [DeviceType.Camera],
15693
- sourceCaps: SOURCE_CAPS,
15694
- cardinality: "many",
15695
- value: DoorbellSourceSlotValueSchema
15696
- };
15697
- var doorbellCapability = {
15698
- name: "doorbell",
15699
- scope: "device",
15700
- deviceNative: true,
15701
- mode: "singleton",
15702
- kind: "wrapper",
15703
- defaultActive: false,
15704
- deviceTypes: [DeviceType.Button, DeviceType.Camera],
15705
- extensionSlots: [DOORBELL_SOURCE_SLOT],
15706
- methods: {},
15707
- events: {
15708
- /**
15709
- * Fires once per physical press. Reolink delivers via Baichuan
15710
- * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
15711
- * release/duration — it's a pulse.
15712
- */
15713
- onPressed: {
15714
- data: DoorbellPressEventSchema,
15715
- fromState: {
15716
- field: "lastPressedAt",
15717
- on: "advance"
15718
- }
15719
- } },
15720
- status: {
15721
- schema: DoorbellStatusSchema,
15722
- kind: "push"
15723
- },
15724
- /**
15725
- * Runtime-state slice — last press timestamp + lifetime press count.
15726
- * Mirrored by the kernel and readable via
15727
- * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
15728
- * without subscribing.
15729
- */
15730
- runtimeState: DoorbellStatusSchema,
15731
- /**
15732
- * Runtime-state durability: **restored** — a monotonic accumulator: the slice IS the record. `lastPressedAt` is content here, not a clock, so it is deliberately NOT volatile.
15733
- *
15734
- * See `RuntimeStateDurability`. Enforced by
15735
- * `scripts/check-runtime-state-durability.ts`.
15736
- */
15737
- durability: "restored"
15738
- };
15739
- var ExtensionSlotIdSchema = _enum(["doorbell-source", "linked-devices"]);
15740
- DeviceType.Camera, DeviceType.Camera;
15646
+ var ExtensionSlotIdSchema = _enum(["linked-devices"]);
15647
+ DeviceType.Camera;
15741
15648
  /** A slot's editable value, discriminated on `slot`. */
15742
- var ExtensionSlotValueSchema = discriminatedUnion("slot", [DoorbellSourceSlotValueSchema.extend({ slot: literal("doorbell-source") }), LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
15649
+ var ExtensionSlotValueSchema = discriminatedUnion("slot", [LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
15743
15650
  DeviceType.Camera;
15744
15651
  var ExtensionSourceAvailabilitySchema = discriminatedUnion("state", [object({ state: literal("ok") }), object({
15745
15652
  state: literal("unreachable"),
@@ -15776,10 +15683,11 @@ var ExtensionSlotSourceSchema = object({
15776
15683
  });
15777
15684
  /**
15778
15685
  * A DIFFERENT authority that gates whether the slot's sources do anything —
15779
- * reported read-only, named, never written through this contract (D62). The
15780
- * virtual doorbell's sources ring only while the wrapper is bound to the
15781
- * camera, and that binding belongs to `deviceManager.setWrapperActive`.
15782
- * `unknown` means the gate could not be read; it is not "inactive" (D315).
15686
+ * reported read-only, named, never written through this contract (D62) — e.g.
15687
+ * a slot whose sources act only while a wrapper is bound to the host, a
15688
+ * binding that belongs to `deviceManager.setWrapperActive`. No slot reports
15689
+ * one today (`linked-devices` answers `null`). `unknown` means the gate could
15690
+ * not be read; it is not "inactive" (D315).
15783
15691
  */
15784
15692
  var ExtensionSlotActivationSchema = object({
15785
15693
  state: _enum([
@@ -15795,7 +15703,7 @@ var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
15795
15703
  /** `<source>:<slotId>` — the routing key for `listCandidates` / `setSlot`. */
15796
15704
  slotRef: string(),
15797
15705
  slot: ExtensionSlotIdSchema,
15798
- /** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
15706
+ /** Names the store written (D62), e.g. `device-manager:_linkedDeviceIds`. */
15799
15707
  authority: string(),
15800
15708
  activation: ExtensionSlotActivationSchema.nullable(),
15801
15709
  value: ExtensionSlotValueSchema,
@@ -20917,12 +20825,26 @@ var CameraSensorRefSchema = object({
20917
20825
  function cameraSensorRefKey(ref) {
20918
20826
  return `${ref.addonId}/${ref.stableId}`;
20919
20827
  }
20920
- /** CS-1 accepts exactly one mode; CS-2 (D673) widens it to confirm / replace. */
20921
- var CameraSensorsModeSchema = _enum(["in-addition"]);
20922
- var CameraSensorsConfigSchema = object({
20923
- mode: CameraSensorsModeSchema,
20924
- sensors: array(CameraSensorRefSchema).min(1).max(8)
20925
- }).superRefine((value, ctx) => {
20828
+ /**
20829
+ * A sensor's mode (D673). `in-addition`: its motion is the camera's motion too.
20830
+ * `replace`: its motion is the camera's, and while one is READY the camera's
20831
+ * own motion no longer counts on its own. `confirm`: while one is READY the
20832
+ * camera's own motion counts only while a confirm sensor is active — with or
20833
+ * without a replace sensor (spec C2, the controller's ruling on Q1).
20834
+ */
20835
+ var CameraSensorModeSchema = _enum([
20836
+ "in-addition",
20837
+ "confirm",
20838
+ "replace"
20839
+ ]);
20840
+ /** A stored sensor with no mode — every CS-1 row — is in addition. */
20841
+ var DEFAULT_CAMERA_SENSOR_MODE = "in-addition";
20842
+ /**
20843
+ * A camera's attached sensors. A CS-1 row carried an envelope
20844
+ * `mode: 'in-addition'`: `z.object` drops it on parse, and the PARSED value is
20845
+ * what the write stores (D672 rule 3), so it is never written again.
20846
+ */
20847
+ var CameraSensorsConfigSchema = object({ sensors: array(CameraSensorRefSchema.extend({ mode: CameraSensorModeSchema.default(DEFAULT_CAMERA_SENSOR_MODE) })).min(1).max(8) }).superRefine((value, ctx) => {
20926
20848
  const seen = /* @__PURE__ */ new Set();
20927
20849
  value.sensors.forEach((ref, index) => {
20928
20850
  const key = cameraSensorRefKey(ref);
@@ -20934,39 +20856,41 @@ var CameraSensorsConfigSchema = object({
20934
20856
  seen.add(key);
20935
20857
  });
20936
20858
  });
20937
- var RefFields = {
20859
+ /** Every attachment names its mode; an older orchestrator that does not is in addition. */
20860
+ var AttachmentFields = {
20938
20861
  addonId: string(),
20939
- stableId: string()
20862
+ stableId: string(),
20863
+ mode: CameraSensorModeSchema.default(DEFAULT_CAMERA_SENSOR_MODE)
20940
20864
  };
20941
20865
  var CameraSensorAttachmentSchema = discriminatedUnion("state", [
20942
20866
  object({
20943
- ...RefFields,
20867
+ ...AttachmentFields,
20944
20868
  state: literal("ready"),
20945
20869
  deviceId: number(),
20946
20870
  name: string(),
20947
20871
  caps: array(string()).readonly()
20948
20872
  }),
20949
20873
  object({
20950
- ...RefFields,
20874
+ ...AttachmentFields,
20951
20875
  state: literal("offline"),
20952
20876
  deviceId: number(),
20953
20877
  name: string(),
20954
20878
  reason: _enum(["offline", "disabled"])
20955
20879
  }),
20956
20880
  object({
20957
- ...RefFields,
20881
+ ...AttachmentFields,
20958
20882
  state: literal("missing"),
20959
20883
  reason: string()
20960
20884
  }),
20961
20885
  object({
20962
- ...RefFields,
20886
+ ...AttachmentFields,
20963
20887
  state: literal("unsupported"),
20964
20888
  deviceId: number(),
20965
20889
  name: string(),
20966
20890
  reason: string()
20967
20891
  }),
20968
20892
  object({
20969
- ...RefFields,
20893
+ ...AttachmentFields,
20970
20894
  state: literal("unreadable"),
20971
20895
  reason: string()
20972
20896
  })
@@ -20979,17 +20903,49 @@ var CameraSensorAttachmentSchema = discriminatedUnion("state", [
20979
20903
  * say.
20980
20904
  */
20981
20905
  var CameraSensorsNotForwardedSchema = _enum(["onboard-not-listed", "no-active-detection"]);
20906
+ /** Whether the camera's OWN motion counts on its own (D673 C2), from its READY sensors (C3). */
20907
+ var CameraOwnMotionSchema = _enum([
20908
+ "always",
20909
+ "when-confirmed",
20910
+ "never"
20911
+ ]);
20912
+ /** Confirm / Replace cannot suppress this camera's motion: its provider still emits it directly (D673 C13). */
20913
+ var CameraSensorsNotEnforcedSchema = _enum(["provider-not-split"]);
20914
+ /**
20915
+ * The camera's OWN onboard motion (D696): the raw input the bridge combines,
20916
+ * never the effective value. The camera's `motion` slice is the effective one,
20917
+ * so a view of the camera's own row reads this.
20918
+ * - `atMs`: when the bridge received the camera's last own edge.
20919
+ * - `detected`: evaluated WHEN THE STATUS IS ASKED — the last edge said `true`
20920
+ * AND it is younger than the window the bridge counts the camera's own motion
20921
+ * for (`motionCooldownMs`, floored at two sustain ticks). A camera that never
20922
+ * sends a clear (heartbeats only) therefore reads `false` once no push came
20923
+ * within the window; a status read is a snapshot, so a live view re-asks.
20924
+ * Absent = this orchestrator has not received the camera's own motion since it
20925
+ * started (unknown, never "quiet"), or an orchestrator older than D696.
20926
+ */
20927
+ var CameraRawLevelSchema = object({
20928
+ detected: boolean(),
20929
+ atMs: number()
20930
+ });
20982
20931
  var CameraSensorsStatusSchema = discriminatedUnion("state", [
20983
- object({ state: literal("not-configured") }),
20932
+ object({
20933
+ state: literal("not-configured"),
20934
+ cameraLevel: CameraRawLevelSchema.optional()
20935
+ }),
20984
20936
  object({
20985
20937
  state: literal("unreadable"),
20986
- reason: string()
20938
+ reason: string(),
20939
+ cameraLevel: CameraRawLevelSchema.optional()
20987
20940
  }),
20988
20941
  object({
20989
20942
  state: literal("configured"),
20990
- mode: CameraSensorsModeSchema,
20943
+ cameraLevel: CameraRawLevelSchema.optional(),
20991
20944
  sensors: array(CameraSensorAttachmentSchema).readonly(),
20992
- notForwarded: CameraSensorsNotForwardedSchema.optional()
20945
+ /** Absent = an orchestrator older than CS-2: it does not say (D315). */
20946
+ cameraMotion: CameraOwnMotionSchema.optional(),
20947
+ notForwarded: CameraSensorsNotForwardedSchema.optional(),
20948
+ notEnforced: CameraSensorsNotEnforcedSchema.optional()
20993
20949
  })
20994
20950
  ]);
20995
20951
  Object.freeze({
@@ -24220,8 +24176,10 @@ var RunnerCameraConfigSchema = object({
24220
24176
  * - `analyzer`: ML/frame-diff motion detection (motion-wasm or other
24221
24177
  * analyzer providers) running on decoded frames in this runner.
24222
24178
  * - `onboard`: camera-native motion pushes (Reolink Baichuan, ONVIF
24223
- * analytics). The provider emits ProviderMotion
24224
- * events; the orchestrator forwards to `reportMotion`.
24179
+ * analytics). The provider emits its raw push as `ProviderMotion`
24180
+ * (D673); the orchestrator's camera-sensors bridge re-emits the camera's
24181
+ * effective motion as `MotionOnMotionChanged`, which is what reaches
24182
+ * `reportMotion`.
24225
24183
  */
24226
24184
  motionSources: MotionSourcesSchema.default(["analyzer"]),
24227
24185
  /**
@@ -24398,7 +24356,15 @@ var RunnerLocalLoadSchema = object({
24398
24356
  hasGpu: boolean(),
24399
24357
  inferenceBackend: string().optional(),
24400
24358
  cpuCores: number().optional()
24401
- })
24359
+ }),
24360
+ /**
24361
+ * Who writes a camera's `motion` runtime slice (D696). `'orchestrator'`: this
24362
+ * runner never writes it. ABSENT = a runner older than D696, which still
24363
+ * writes the slice from its phase machine; the orchestrator does not write
24364
+ * while any runner node is absent-or-unanswered, so one slice never has two
24365
+ * writers.
24366
+ */
24367
+ motionSliceWriter: literal("orchestrator").optional()
24402
24368
  });
24403
24369
  /**
24404
24370
  * Aggregate runtime metrics for the runner's whole local pool. Mirrors the
@@ -30031,6 +29997,70 @@ onStatusChanged: { data: object({
30031
29997
  * whether persisting is worth a SQLite commit. */
30032
29998
  volatileStateFields: ["lastChangedAt"]
30033
29999
  };
30000
+ var DoorbellStatusSchema = object({
30001
+ /** Ms epoch of the last press. null = never observed since this provider started. */
30002
+ lastPressedAt: number().nullable(),
30003
+ /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
30004
+ pressCountSinceStart: number()
30005
+ });
30006
+ var DoorbellPressEventSchema = object({
30007
+ deviceId: number(),
30008
+ timestamp: number()
30009
+ });
30010
+ /**
30011
+ * Doorbell button cap. Two kinds of provider serve it, and to every consumer
30012
+ * they are the same cap:
30013
+ *
30014
+ * - **Native** providers, registered per device by a driver addon through
30015
+ * `ctx.registerNativeCap` — a `DeviceType.Button` accessory with
30016
+ * `role: DeviceRole.Doorbell`, or the camera itself (Reolink registers at
30017
+ * camera level). They emit `onPressed` on every firmware ring.
30018
+ * - **A composed doorbell** (D670, D671): a camera's customization gives it
30019
+ * the cap, its `lastPressedAt` rising from the sources the operator
30020
+ * assigned in Customize → Doorbell, and the composer emits `onPressed`
30021
+ * from that field's advance (`fromState`).
30022
+ *
30023
+ * The DeviceEventPropagator re-emits `onPressed` on the camera parent.
30024
+ */
30025
+ var doorbellCapability = {
30026
+ name: "doorbell",
30027
+ scope: "device",
30028
+ deviceNative: true,
30029
+ mode: "singleton",
30030
+ deviceTypes: [DeviceType.Button, DeviceType.Camera],
30031
+ methods: {},
30032
+ events: {
30033
+ /**
30034
+ * Fires once per physical press. Reolink delivers via Baichuan
30035
+ * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
30036
+ * release/duration — it's a pulse.
30037
+ */
30038
+ onPressed: {
30039
+ data: DoorbellPressEventSchema,
30040
+ fromState: {
30041
+ field: "lastPressedAt",
30042
+ on: "advance"
30043
+ }
30044
+ } },
30045
+ status: {
30046
+ schema: DoorbellStatusSchema,
30047
+ kind: "push"
30048
+ },
30049
+ /**
30050
+ * Runtime-state slice — last press timestamp + lifetime press count.
30051
+ * Mirrored by the kernel and readable via
30052
+ * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
30053
+ * without subscribing.
30054
+ */
30055
+ runtimeState: DoorbellStatusSchema,
30056
+ /**
30057
+ * Runtime-state durability: **restored** — a monotonic accumulator: the slice IS the record. `lastPressedAt` is content here, not a clock, so it is deliberately NOT volatile.
30058
+ *
30059
+ * See `RuntimeStateDurability`. Enforced by
30060
+ * `scripts/check-runtime-state-durability.ts`.
30061
+ */
30062
+ durability: "restored"
30063
+ };
30034
30064
  /**
30035
30065
  * Enum-state sensor — a string value picked from a finite option set.
30036
30066
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -32175,9 +32205,9 @@ var MotionStatusSchema = object({
32175
32205
  /** Ms epoch of the last detected-true observation. Null if never detected. */
32176
32206
  lastDetectedAt: number().nullable(),
32177
32207
  /**
32178
- * `MOTION_CLOSE_AFTER_MS` while `detected: true` on a `Camera` device,
32179
- * `null` while false and on every `Sensor` device (D475) — see that
32180
- * constant's doc for the one-authority rule.
32208
+ * `MOTION_CLOSE_AFTER_MS` while `detected: true` on a `Camera` device (the
32209
+ * orchestrator writes it, D696), `null` while false and on every `Sensor`
32210
+ * device (D475) — see that constant's doc for the one-authority rule.
32181
32211
  *
32182
32212
  * ## Reading this field still arms nothing
32183
32213
  *
@@ -32195,34 +32225,31 @@ var MotionStatusSchema = object({
32195
32225
  * `addon-export-alexa/src/motion-clear-hold.ts` and
32196
32226
  * `addon-export-hap`'s `RESET_DEBOUNCE_MS` both do.
32197
32227
  *
32198
- * ## Who writes it
32199
- *
32200
- * - **Cameras** — the runner's phase machine, `active → watching` on
32201
- * `cooldown_expired`, which then writes this slice with
32202
- * `detected: false` (`handlePhaseChanged` in
32203
- * `pipeline-runner/index.ts`). It produces the FALLING edge, which
32204
- * matters most for the sources that only ever push a rising one:
32205
- * Reolink emits `MotionOnMotionChanged { detected: true }` and never
32206
- * a false.
32207
- * - **Sensors** (Home Assistant binary sensors, Homematic) — the
32208
- * provider pushes the false itself, from the upstream system's own
32209
- * state change. No phase machine is involved.
32210
- *
32211
- * ### The phase machine is CANONICAL, not sole — and that is a defect
32212
- *
32213
- * An earlier revision of this docblock (mine, 2026-09-12) claimed the
32214
- * phase machine is the sole writer for a camera. It is not.
32215
- * `hikvision-camera.ts:3464` and `amcrest-camera.ts:445` both call
32216
- * `setCapSlice(motionCapability, …)` on their own rising edge, and
32217
- * Hikvision's comment says why: it read THIS docblock, agreed the
32218
- * runner is canonical, and wrote anyway to avoid per-tick churn. So
32219
- * two authorities can disagree about one slice, which this repo
32220
- * forbids, and the doc said otherwise — which is worse than saying
32221
- * nothing, because it reads as verification.
32222
- *
32223
- * This predates D475 and is not fixed there: the fix touches every
32224
- * camera provider. Recorded in D475's Consequences. Do not restore the
32225
- * "sole writer" wording without also removing the other writers.
32228
+ * ## Who writes it (D696)
32229
+ *
32230
+ * - **Cameras** — the orchestrator, and nothing else. It writes the camera's
32231
+ * EFFECTIVE motion (`addon-pipeline-orchestrator`
32232
+ * `camera-sensors/motion-slice.ts`), alongside the effective
32233
+ * `MotionOnMotionChanged` it emits or receives: the camera-sensors bridge
32234
+ * for a split camera, an unsplit emitter's direct event, the analyzer and
32235
+ * device activity, as the camera's `motionSources` counts them. A source
32236
+ * that never sends a clear falls one window after its last `true`.
32237
+ * `lastDetectedAt` moves only on an effective rise. No camera provider
32238
+ * writes this slice (not even a boot seed), and neither does the runner's
32239
+ * phase machine: its phase is "a session is attached" for every on-motion
32240
+ * session, not motion, and it now has its own `DetectionPhaseTransition`.
32241
+ * A provider's `isDetected` answers from the hub's slice.
32242
+ * - **Sensors** (Home Assistant binary sensors, Homematic, Matter, Alexa
32243
+ * imports) — the provider pushes both edges itself, from the upstream
32244
+ * system's own state change. That is the sensor's own fact.
32245
+ *
32246
+ * `scripts/check-onboard-motion-emitters.ts` refuses any other writer of a
32247
+ * `motion` slice; each sensor writer is a named exemption.
32248
+ *
32249
+ * This replaces the D475 §4 rule ("the phase machine is the sole writer").
32250
+ * That rule was never true: Hikvision and Amcrest wrote the slice raw beside
32251
+ * it, every provider seeded it at boot, and every session left it
32252
+ * `detected: true` after its detach.
32226
32253
  */
32227
32254
  autoClearAfterMs: number().nullable()
32228
32255
  });
@@ -32294,11 +32321,12 @@ onMotionChanged: { data: MotionOnMotionChangedDataSchema } },
32294
32321
  */
32295
32322
  runtimeState: MotionStatusSchema,
32296
32323
  /**
32297
- * Runtime-state durability: **session** — every writer of this slice
32324
+ * Runtime-state durability: **session** — the writer of this slice
32298
32325
  * writes only on an EDGE, so a restored `detected: true` would stay
32299
32326
  * frozen until the next one instead of being corrected. The next edge
32300
- * re-publishes the real state. (On who the writers are, and why there
32301
- * is more than one, see `autoClearAfterMs` above.)
32327
+ * re-publishes the real state, and the orchestrator re-publishes every
32328
+ * camera when it starts writing (D696; on who writes it, see
32329
+ * `autoClearAfterMs` above).
32302
32330
  *
32303
32331
  * See `RuntimeStateDurability`. Enforced by
32304
32332
  * `scripts/check-runtime-state-durability.ts`.
@@ -34681,8 +34709,7 @@ var ptzAutotrackCapability = {
34681
34709
  deviceNative: true,
34682
34710
  mode: "singleton",
34683
34711
  /**
34684
- * Two kinds of provider behind one name, the same shape `snapshot` and
34685
- * `doorbell` use:
34712
+ * Two kinds of provider behind one name, the same shape `snapshot` uses:
34686
34713
  *
34687
34714
  * - **Native**: the camera's own firmware autotrack, registered per device by
34688
34715
  * the driver when the probe says it exists (Reolink: yes; Hikvision:
@@ -34690,7 +34717,7 @@ var ptzAutotrackCapability = {
34690
34717
  * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
34691
34718
  * which follows a subject with `ptz.move` pulses driven by the detection
34692
34719
  * pipeline. `defaultActive: false` — the operator binds it per camera in
34693
- * the bindings UI, exactly as `virtual-doorbell` is bound.
34720
+ * the bindings UI.
34694
34721
  *
34695
34722
  * The binding chooses WHICH implementation answers; `setEnabled` stays the
34696
34723
  * one switch for the function either way. A camera never has both driving its
@@ -51648,6 +51675,25 @@ TimelapseRuleInputSchema.extend({
51648
51675
  createdAt: number(),
51649
51676
  updatedAt: number()
51650
51677
  });
51678
+ async function readHubMotionDetected(deviceState, deviceId) {
51679
+ let slice;
51680
+ try {
51681
+ slice = await deviceState.getCapSlice.query({
51682
+ deviceId,
51683
+ capName: "motion"
51684
+ });
51685
+ } catch (err) {
51686
+ const reason = err instanceof Error ? err.message : String(err);
51687
+ throw new Error(`motion state of device ${deviceId} is unreadable: ${reason}`, { cause: err });
51688
+ }
51689
+ const detected = slice?.["detected"];
51690
+ if (typeof detected !== "boolean") throw new Error(`motion state of device ${deviceId} is not published yet (unknown)`);
51691
+ return detected;
51692
+ }
51693
+ MotionOnMotionChangedDataSchema.omit({ triggeredBy: true });
51694
+ function providerMotionEvent(source, data) {
51695
+ return createEvent(EventCategory.ProviderMotion, source, data);
51696
+ }
51651
51697
  object({
51652
51698
  /**
51653
51699
  * Fraction of the box's own size added on EACH side before cutting.
@@ -56116,16 +56162,18 @@ var WyzeCamera = class extends BaseDevice {
56116
56162
  return null;
56117
56163
  }
56118
56164
  }
56165
+ /**
56166
+ * The native `motion` cap. This provider writes NO `motion` slice (D696): the
56167
+ * orchestrator is its only writer, with the EFFECTIVE motion, on the hub.
56168
+ * `isDetected` answers from the HUB's slice; an unreadable hub, or a slice
56169
+ * nobody has published yet, fails the call — never `false` for unknown
56170
+ * (D49, D393).
56171
+ */
56119
56172
  registerMotionProvider() {
56120
56173
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
56121
- if (deviceId !== this.id) return false;
56122
- return this.state.motion.detected ?? false;
56174
+ if (deviceId !== this.id) throw new Error(`WyzeCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
56175
+ return readHubMotionDetected(this.ctx.api.deviceState, this.id);
56123
56176
  } });
56124
- this.setCapSlice(motionCapability, {
56125
- detected: false,
56126
- lastDetectedAt: null,
56127
- autoClearAfterMs: null
56128
- });
56129
56177
  }
56130
56178
  /**
56131
56179
  * Start the configured motion source. `cloud` polls the Wyze events API
@@ -56169,12 +56217,14 @@ var WyzeCamera = class extends BaseDevice {
56169
56217
  }
56170
56218
  }
56171
56219
  /**
56172
- * Emit `MotionOnMotionChanged` (`source: 'onboard'`). The pipeline-runner
56173
- * subscribes and is the sole writer of the `motion` runtime-state slice
56174
- * — symmetric with the Reolink onboard-motion path.
56220
+ * Emit the camera's RAW motion push as `ProviderMotion`
56221
+ * (`source: 'onboard'`, D673). The orchestrator's camera-sensors bridge
56222
+ * re-emits it as the effective `MotionOnMotionChanged`, and the
56223
+ * orchestrator is the only writer of the camera's `motion` runtime-state
56224
+ * slice (D696) — symmetric with the Reolink onboard-motion path.
56175
56225
  */
56176
56226
  emitMotion(timestamp) {
56177
- this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
56227
+ this.ctx.eventBus.emit(providerMotionEvent({
56178
56228
  type: "device",
56179
56229
  id: this.id,
56180
56230
  addonId: WYZE_ADDON_ID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-wyze",
3
- "version": "0.2.121",
3
+ "version": "0.2.122",
4
4
  "description": "Wyze camera device-provider addon for CamStack — wraps the @apocaliss92/wyze-bridge-js P2P/DTLS client, feeding the stream-broker via the pull-rfc4571 lazy-publish path (a structural twin of addon-provider-reolink)",
5
5
  "keywords": [
6
6
  "camstack",