@mega-yfue/eufy-sdk 0.6.0-beta.1 → 0.6.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3117,6 +3117,7 @@ var DeviceType = {
3117
3117
  LOCK_85D0: 202,
3118
3118
  LOCK_85V0: 203,
3119
3119
  LOCK_85P0: 209,
3120
+ WALL_LIGHT_CAM_T86C1: 298,
3120
3121
  NVR_S4_MAX: 300,
3121
3122
  CAMERA_POE_S4: 301,
3122
3123
  WALL_LIGHT_CAM_81A0: 10005,
@@ -3665,7 +3666,8 @@ var FLOODLIGHT_TYPES = /* @__PURE__ */ new Set([
3665
3666
  ]);
3666
3667
  var WALL_LIGHT_TYPES = /* @__PURE__ */ new Set([
3667
3668
  DeviceType.WALL_LIGHT_CAM,
3668
- DeviceType.WALL_LIGHT_CAM_81A0
3669
+ DeviceType.WALL_LIGHT_CAM_81A0,
3670
+ DeviceType.WALL_LIGHT_CAM_T86C1
3669
3671
  ]);
3670
3672
  var HOMEBASE_TYPES = /* @__PURE__ */ new Set([
3671
3673
  DeviceType.STATION,
@@ -3681,7 +3683,8 @@ var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
3681
3683
  var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
3682
3684
  var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
3683
3685
  var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
3684
- var isNvrMediaCamera = (ctx) => ctx.model === "T8E00" || ctx.model === "T8P00";
3686
+ var NVR_MEDIA_CAMERA_MODELS = /* @__PURE__ */ new Set(["T8E00", "T8P00", "T8P10", "T8162", "T8410", "T8214"]);
3687
+ var isNvrMediaCamera = (ctx) => ctx.model !== void 0 && NVR_MEDIA_CAMERA_MODELS.has(ctx.model);
3685
3688
  var isNvrDualLensCamera = (ctx) => ctx.model === "T8E00";
3686
3689
  var isCallDoorbell = (ctx) => ctx.model === "T8223" || ctx.model === "T8224";
3687
3690
  var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
@@ -4711,7 +4714,7 @@ function decodePowerSource(raw) {
4711
4714
  function recordSetting(param, value, ctx) {
4712
4715
  return setStationScalar(param, value, ctx.channel);
4713
4716
  }
4714
- var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1", "T8423", "T8E00", "T8P00"];
4717
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1", "T8423", "T8E00", "T8P00", "T8P10"];
4715
4718
  function cameraPowerTier(model, capabilities) {
4716
4719
  return capabilities.has("battery") && notMainsCamera({ model }) ? "battery" : "wired";
4717
4720
  }
@@ -5333,2084 +5336,2086 @@ var AUDIO = {
5333
5336
  }
5334
5337
  };
5335
5338
 
5336
- // dist/model/capabilities/camera.js
5337
- var CAMERA_CMD = {
5338
- /** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
5339
- WIFI_RSSI: 1142,
5340
- /** Camera on/off. Read and write polarity depend on the family and topology. */
5341
- CAMERA_ENABLE: 1035,
5342
- /** Wrapped T8410 power switch on HomeBase 3. */
5343
- PRIVACY_MODE: 6250,
5339
+ // dist/model/capabilities/doorbell.js
5340
+ import { setTimeout as sleep } from "node:timers/promises";
5341
+ var DOORBELL_MODELS = /* @__PURE__ */ new Set([
5342
+ "T820",
5343
+ "T8200",
5344
+ "T8201",
5345
+ "T8202",
5346
+ "T8203",
5347
+ "T8210",
5348
+ "T8212",
5349
+ "T8213",
5350
+ "T8214",
5351
+ "T8220",
5352
+ "T8221",
5353
+ "T8222"
5354
+ ]);
5355
+ var DOORBELL_TYPES = [
5356
+ DeviceType.DOORBELL,
5357
+ DeviceType.BATTERY_DOORBELL,
5358
+ DeviceType.BATTERY_DOORBELL_2,
5359
+ DeviceType.BATTERY_DOORBELL_PLUS,
5360
+ DeviceType.DOORBELL_SOLO,
5361
+ DeviceType.BATTERY_DOORBELL_PLUS_E340
5362
+ ];
5363
+ var DOORBELL_CMD = {
5344
5364
  /**
5345
- * Camera status LED on/off — the "power/recording" indicator. The
5346
- * app JS has a sibling param `1056` (`APP_CMD_LIVEVIEW_LED_SWITCH`) for the SAME UI "Status Light"
5347
- * setting on some other model/generation — confirmed real (a full parser class exists) but tested
5348
- * live with no effect on a T8425 (which uses 1045, shipped here); which model actually uses 1056
5349
- * is unconfirmed. Don't wire 1056 as an alias of this without per-model evidence.
5365
+ * Doorbell "quick response" — make the doorbell PLAY a stored voice reply. Same 136-byte
5366
+ * direct-binary shape as the camera setters: `value` = the `voice_id` to play. Reversed from a
5367
+ * live capture (outer-cmd 1706, signCode 8, on the doorbell's `device_channel`) + the app's
5368
+ * `PlayQuickResponseParser` (`{cmd:1706,voice_id}`).
5350
5369
  */
5351
- DEV_LED_SWITCH: 1045,
5370
+ QUICK_RESPONSE: 1706,
5352
5371
  /**
5353
- * Rotate the image 180° on/off. Direct param, value 0 = normal, 1 = flipped. The app's constant is
5354
- * `INDOOR_ROTATE_IMAGE`, but it is NOT indoor-only — ✅ verified live on an OUTDOOR floodlight cam
5355
- * (T8425), so we drop the misleading "indoor". App parser: `{cmd:1207, params:{enable:0|1}}`.
5372
+ * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box,
5373
+ * the app's "existing doorbell chime" (as opposed to / in addition to the HomeBase acting as the chime,
5374
+ * param 1702 `chimeSwitch` above; toggling each switch alone wrote one parameter and named the pair). Same
5375
+ * 136-byte direct-binary shape as `QUICK_RESPONSE`/camera on-off: `[u32 channel][u32 value][account_id
5376
+ * ASCII, zero-padded to 128 bytes]`, outer P2P cmd = 1703 itself, signCode 8, on the device's own
5377
+ * channel. Reversed from a live capture (outer-cmd 1703, signCode 8, captured on the doorbell's
5378
+ * `device_channel` — channel 2 for that unit, NOT a wire constant, always use `ctx.channel`):
5379
+ * turning ON sent `value=1`, turning OFF sent `value=0` — a plain boolean, 1=on/0=off.
5380
+ * App `APP_CMD_BAT_DOORBELL_MECHANICAL_CHIME_SWITCH`.
5356
5381
  */
5357
- ROTATE_IMAGE: 1207,
5382
+ MECHANICAL_CHIME_SWITCH: 1703,
5358
5383
  /**
5359
- * On-screen watermark / OSD overlay (app `CMD_SET_DEVS_OSD`). ✅ Wire verified live on T8425 (ch3):
5360
- * a **3-value enum**, not a bool — 0 = off, 1 = timestamp, 2 = timestamp + logo. Direct-binary
5361
- * `[channel][value]`. (Labels/enum in {@link Watermark}.)
5384
+ * Whether the HOMEBASE acts as the doorbell's chime — the app's "HomeBase as chime" switch, as opposed
5385
+ * to the wired chime box {@link DOORBELL_CMD.MECHANICAL_CHIME_SWITCH} drives. The app constant reads
5386
+ * `CHIME_SWITCH`, which is why this was long described as a separate plug-in chime; toggling that one
5387
+ * switch alone on a T8210 behind a HomeBase wrote 1702 and nothing else, and the account has no
5388
+ * plug-in chime at all. Same 136-byte direct-binary shape as
5389
+ * that sibling: `[u32 channel][u32 value][account_id ASCII, zero-padded to 128 bytes]`, outer P2P cmd
5390
+ * 1702 itself, signCode 8, on the device's own channel. Reversed from a live capture (outer-cmd 1702,
5391
+ * signCode 8, on the doorbell's `device_channel` — 2 for that unit, NOT a wire constant, always use
5392
+ * `ctx.channel`): turning OFF sent `value=0`, turning ON sent `value=1` — a plain boolean.
5393
+ * App `APP_CMD_BAT_DOORBELL_CHIME_SWITCH`.
5362
5394
  */
5363
- SET_DEVS_OSD: 1214,
5395
+ CHIME_SWITCH: 1702,
5364
5396
  /**
5365
- * Video-doorbell status-LED on/off. Rides the `1350` SET_PAYLOAD envelope
5366
- * (`{account_id,cmd:1716,mChannel,mValue3:1716,payload:{light_enable:0|1}}`),
5367
- * signCode 8. ✅ verified live on Doorbell Dual T8214. Family-specific wire for the same semantic
5368
- * status LED setting ordinary cameras report under 1045.
5397
+ * Wide Dynamic Range (WDR) image switch — a video/image tone-mapping setting that widens the
5398
+ * exposure range in high-contrast scenes (bright sky behind a visitor, etc). SAME 136-byte
5399
+ * direct-binary shape/polarity/capture session as `MECHANICAL_CHIME_SWITCH` immediately above: outer
5400
+ * P2P cmd 1704, signCode 8, device channel, `value` 1=on/0=off.
5401
+ * App `APP_CMD_BAT_DOORBELL_WDR_SWITCH`.
5369
5402
  */
5370
- DOORBELL_LED: 1716,
5403
+ WDR_SWITCH: 1704,
5371
5404
  /**
5372
- * Night-vision mode (app `NIGHT_VISION_TYPE`). Enum: 0 = off, 1 = infrared/auto (B&W), 2 = full colour.
5373
- * ✅ All three verified live on T8425: `1350` SET_PAYLOAD, inner cmd 1277, `payload:{channel:<deviceCh>,
5374
- * night_sion:N}`, mChannel 0, mValue3 0. Standalone (SINGLE-connect) uses direct `IC_NIGHT_VISION_TYPE`
5375
- * (1013). (Some models omit full colour; enum in {@link NightVision}.)
5405
+ * Doorbell chime ("ding-dong") volume — app `APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME`. UNLIKE the 1703/1704 direct-binary switches above, this rides the
5406
+ * `SET_PAYLOAD` (1350) JSON envelope: `{account_id,cmd:1717,mChannel:<deviceCh>,mValue3:0,
5407
+ * payload:{dingdong_volume:N}}`, GCM signCode 8, on the doorbell's `device_channel` (2 for the
5408
+ * captured unit — always use `ctx.channel`, don't hardcode). `mValue3` is an EXPLICIT `0`, not the
5409
+ * `SET_PAYLOAD` default of `mValue3:cmd` (see {@link module:./intent setPayload}'s doc comment) —
5410
+ * both live frames captured showed `mValue3:0`. Reversed from a live capture (values 3 and 25
5411
+ * observed) — a 0-100 loudness level, distinct from the already-shipped `ringtoneVolume` (1708,
5412
+ * direct-binary, `audio.ts`), which is a DIFFERENT control.
5376
5413
  */
5377
- NIGHT_VISION_TYPE: 1277,
5378
- /** T8442 infrared night vision on/off: direct L1 int-string, hardware verified (0/1). */
5379
- IC_NIGHT_VISION_TYPE: 1013,
5414
+ DINGDONG_VOLUME: 1717,
5380
5415
  /**
5381
- * Push-notification STYLE — how much a detection push carries: text alone, a thumbnail, or text
5382
- * followed by a thumbnail. App `CMD_INDOOR_PUSH_NOTIFY_TYPE`, and the "indoor" is a misnomer: the
5383
- * wire is confirmed on an OUTDOOR standalone camera (T8171). The app's id→name table carries TWO
5384
- * aliases for this id, `ENTER_OTA` and `INDOOR_PUSH_NOTIFY_TYPE`; the confirmed behaviour is the
5385
- * notification style.
5386
- *
5387
- * Wire verified live on a T8171 (standalone, own channel, reached over the cloud/WAN path): the
5388
- * `1700` CONTROL_PAYLOAD wrapper, signCode 8, plaintext
5389
- * `{"commandType":6020,"data":{"value":N,"transaction":"<epoch ms>"}}`. All three values captured
5390
- * byte-exact, each read back on the cloud param within ~10s. The station answers on the same wrapper
5391
- * ~600ms later with a 4-byte body — the command's int32 result — so this write is not
5392
- * fire-and-forget, unlike the direct-binary switches.
5393
- * (Values in {@link NotificationStyle}.)
5416
+ * Doorbell chime ("ding-dong") RINGTONE SELECTION — app `APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE`.
5417
+ * SAME `SET_PAYLOAD` (1350) JSON envelope shape as
5418
+ * `DINGDONG_VOLUME` immediately above: `{account_id,cmd:1718,mChannel:<deviceCh>,mValue3:0,
5419
+ * payload:{dingdong_ringtone:N}}`, GCM signCode 8, doorbell `device_channel`, explicit `mValue3:0`.
5420
+ * Reversed from a live capture (values 4 and 0 observed) — `N` is an INTEGER INDEX picking one of
5421
+ * several stored chime tones, NOT a boolean.
5394
5422
  */
5395
- PUSH_NOTIFY_TYPE: 6020,
5423
+ DINGDONG_RINGTONE: 1718,
5396
5424
  /**
5397
- * **Sound detection** switch — whether the camera triggers on what it hears, beside the motion
5398
- * trigger it triggers on what it sees.
5425
+ * Live-view quality AND the video encoding format, both in one integer — app
5426
+ * `CMD_BAT_DOORBELL_VIDEO_QUALITY`. The value is `quality + (highCompression ? 5 : 0)`, where quality
5427
+ * is 0 Auto / 1 Low / 2 Medium / 3 High ({@link DoorbellVideoQuality}). The app drives the two from
5428
+ * separate menus and each write carries both, so setting either requires the other's current value —
5429
+ * which is why neither is a derived setter.
5399
5430
  *
5400
- * Wire observed live on an indoor pan-tilt (standalone, mains, own channel): the `1700`
5401
- * CONTROL_PAYLOAD wrapper, plaintext `{"commandType":6043,"data":{"status":0|1}}`. Both directions
5402
- * captured byte-exact and read back on the cloud param, twice each; no other parameter moved.
5403
- */
5404
- SOUND_DETECTION: 6043,
5405
- /**
5406
- * How loud a sound has to be to trigger {@link CAMERA_CMD.SOUND_DETECTION} — the app's three-position
5407
- * control writes `1` lowest, `3` mid, `5` highest.
5431
+ * The encoding is reversed from writing every option in the app and reading the parameter back: High
5432
+ * on high compression is 8 and on low compression 3, Low on low compression 1, Auto on high
5433
+ * compression 5. Six of the eight combinations were observed that way; `0` (Auto, low compression) and
5434
+ * `2` (Medium, low compression) follow the same arithmetic but were not individually written.
5408
5435
  *
5409
- * Wire observed live on the same camera: `{"commandType":6044,"data":{"index":N}}` in the `1700`
5410
- * wrapper. The payload field is `index`, NOT the `value` its neighbours use — each of these
5411
- * commands names its field differently, so there is no generic setter for the family.
5412
- */
5413
- SOUND_DETECTION_SENSITIVITY: 6044,
5414
- /**
5415
- * WHICH sounds trigger {@link CAMERA_CMD.SOUND_DETECTION} — all sound, or crying alone.
5436
+ * Wire captured byte-exact from the app on a T8210 behind a HomeBase: outer P2P cmd 1705 itself — no
5437
+ * wrapper — signCode 8, on the doorbell's own channel, the same 136-byte direct-binary
5438
+ * `[u32 channel][u32 value][account_id ASCII pad 128]` struct as its 1703/1704 siblings. The station
5439
+ * answers with an unencrypted level-1 frame carrying 0.
5416
5440
  *
5417
- * Wire observed live: `{"commandType":6046,"data":{"type":N}}` in the `1700` wrapper, both values
5418
- * captured and read back. (Values in {@link SoundDetectionType}.)
5441
+ * Replayed from this SDK and confirmed on that device: three writes in one session, each read back off
5442
+ * the parameter — quality alone, then the encoding format with the quality held, then a restore.
5419
5443
  */
5420
- SOUND_DETECTION_TYPE: 6046,
5444
+ VIDEO_QUALITY: 1705,
5421
5445
  /**
5422
- * **Anti-theft detection** switch (app `APP_CMD_EAS_SWITCH`). Despite the "EAS" name the app's own
5423
- * parser maps this id onto `anti_theft_detection_switch`, so the camera member uses that semantic
5424
- * name. The app's "EAS" resource strings mix emergency- and anti-theft-worded copy; the parser is
5425
- * the tiebreak.
5426
- *
5427
- * Wire from the app's own JS: a scalar `params:{value:0|1}`. Emitted with the **adaptive** form so
5428
- * topology picks the level — level-2/direct on a HomeBase-attached device, level-1 on a standalone.
5429
- *
5430
- * ⚠️ Replay + readback confirmed on a **HomeBase-attached** T8425 only (1015 read `0` → write → `1`
5431
- * → restore): the frame is confirmed accepted and persisted, NOT byte-compared to the app's. The
5432
- * **standalone** path is unverified, and newer (v3) devices use a *different* id for the same
5433
- * switch — `APP_CMD_NEW_EAS_SWITCH` (2735) — which has no path here; don't assume 1015 drives them.
5446
+ * Doorbell quick-response LIST fetch — the sub-command carried inside a `SET_PAYLOAD` (1350)
5447
+ * wrapper (app `APP_CMD_GET_CUSTOMIZE_VICOE_LIST`). The station replies with a `NOTIFY_PAYLOAD`
5448
+ * (1351) frame whose JSON is `{cmd:6237, payload:{voice_list:[{voice_id,voice_name,voice_path}]}}`.
5449
+ * Reversed from the app's `GetQuickResponseListP2pParser` + the capture heap. NOTE: 6238
5450
+ * (`APP_CMD_GET_QUICK_RESPONSE`) is the single-file DOWNLOAD, not the list.
5434
5451
  */
5435
- EAS_SWITCH: 1015,
5452
+ GET_QUICK_RESPONSE_LIST: 6237
5453
+ };
5454
+ var DoorbellRingtone = {
5455
+ Default: 0,
5456
+ Silent: 1,
5457
+ Beacon: 2,
5458
+ Chord: 3,
5459
+ Christmas: 4,
5460
+ Circuit: 5,
5461
+ Clock: 6,
5462
+ Ding: 7,
5463
+ Hillside: 8,
5464
+ Presto: 9
5465
+ };
5466
+ var DoorbellVideoQuality = {
5467
+ /** The doorbell picks a quality from the link. */
5468
+ Auto: 0,
5469
+ Low: 1,
5470
+ Medium: 2,
5471
+ High: 3
5472
+ };
5473
+ function composite(raw) {
5474
+ if (typeof raw !== "number" && (typeof raw !== "string" || raw.trim() === ""))
5475
+ return void 0;
5476
+ const v = Number(raw);
5477
+ return Number.isInteger(v) && v >= 0 && v <= 8 && v % 5 <= 3 ? v : void 0;
5478
+ }
5479
+ function parseQuickResponses(voiceList) {
5480
+ return (voiceList ?? []).map((v) => {
5481
+ const path = typeof v.voice_path === "string" ? v.voice_path : "";
5482
+ return {
5483
+ voiceId: Number(v.voice_id),
5484
+ name: typeof v.voice_name === "string" ? v.voice_name : "",
5485
+ path,
5486
+ custom: !path.startsWith("/system/snd/")
5487
+ };
5488
+ });
5489
+ }
5490
+ var DOORBELL_MEMBERS = {
5436
5491
  /**
5437
- * RECORDING quality — what gets stored, not the live view; {@link CAMERA_CMD.STREAMING_QUALITY_SET}
5438
- * below is the live one, and the app's name for this id is `multicamSetRecordQuailty`.
5439
- * Wire confirmed on a T8425 ch3: `1350` SET_PAYLOAD, inner cmd 2731,
5440
- * `payload:{channel:0, mode:0, primary_view:0, quality:N}`, mValue3 0, on the device channel. Tiers
5441
- * 1/2/3, named in {@link RECORDING_QUALITY_TIERS} — no tier 0 on this wire, where streaming has one.
5442
- * That is a fact about 2731 on the cameras it was confirmed on, not about recording everywhere. A
5443
- * doorbell has no recording-quality setting at all, and its live-view quality rides neither 2730 nor
5444
- * 2731 but 1705, on a domain of its own (5 = Auto, 6/7/8 = Low/Medium/High, all four observed).
5492
+ * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
5493
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
5494
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
5495
+ * inferred from the shared param range.
5445
5496
  */
5446
- RECORDING_QUALITY_SET: 2731,
5497
+ chimeSwitch: {
5498
+ param: DOORBELL_CMD.CHIME_SWITCH,
5499
+ type: "bool",
5500
+ kind: "boolean",
5501
+ provenance: "verified",
5502
+ description: `The HomeBase plays the doorbell's chime (1702; the app's "HomeBase as chime" switch \u2014 write wire-confirmed live on T8210).`,
5503
+ write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
5504
+ },
5447
5505
  /**
5448
- * LIVE-VIEW quality — a separate setting from {@link CAMERA_CMD.RECORDING_QUALITY_SET}, and the app's
5449
- * names for the two invert what they suggest: 2730 is `multicamSetVideoQuailty` and drives the
5450
- * Streaming Quality picker, while 2731 is `multicamSetRecordQuailty` and drives Recording Quality.
5451
- * Changing one leaves the other's parameter untouched, which is how they were told apart.
5452
- *
5453
- * Unlike recording it offers a tier 0, `Auto`. Wire verified live on a T8170 (standalone): the `1350`
5454
- * SET_PAYLOAD envelope, signCode 8, plaintext `{"cmd":2730,"payload":{"transaction":"<epoch ms>",
5455
- * "quality":N,"channel":0,"mode":0,"primary_view":0},"account_id":…}`. All four tiers captured
5456
- * byte-exact and read back on parameter 1020; the station echoes the same payload back as a `1351`
5457
- * NOTIFY_PAYLOAD carrying the same `transaction`.
5506
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
5507
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
5458
5508
  */
5459
- STREAMING_QUALITY_SET: 2730
5460
- };
5461
- function hasReportedEasSwitch(source) {
5462
- return source.paramIds?.has(CAMERA_CMD.EAS_SWITCH) === true || Object.hasOwn(source.params ?? {}, CAMERA_CMD.EAS_SWITCH);
5463
- }
5464
- var Watermark = {
5465
- /** No timestamp or logo. */
5466
- Off: 0,
5467
- /** Timestamp only. */
5468
- Timestamp: 1,
5469
- /** Timestamp + eufy logo. */
5470
- TimestampAndLogo: 2
5471
- };
5472
- var NotificationStyle2 = {
5473
- /** The push carries text alone. */
5474
- TextOnly: 1,
5475
- /** The push carries a thumbnail of the detection. */
5476
- IncludedThumbnail: 2,
5477
- /** The push arrives as text, then updates with a thumbnail. */
5478
- TextFirstThenThumbnail: 3
5479
- };
5480
- var SoundDetectionType = {
5481
- /** Crying alone triggers a detection. */
5482
- Crying: 1,
5483
- /** Any sound loud enough for the sensitivity triggers a detection. */
5484
- AllSound: 2
5485
- };
5486
- var NightVision = {
5487
- /** Off — never use infrared. */
5488
- Off: 0,
5489
- /** Infrared / "B&W Auto" — black-and-white night vision. */
5490
- Infrared: 1,
5491
- /** Full colour night vision (models with a spotlight / starlight sensor). */
5492
- FullColor: 2
5493
- };
5494
- var RecordingQuality = {
5495
- HD720: "HD (720P)",
5496
- FullHD1080: "Full HD (1080P)",
5497
- Max: "Max"
5498
- };
5499
- var RECORDING_QUALITY_TIERS = {
5500
- 1: "HD (720P)",
5501
- 2: "Full HD (1080P)",
5502
- 3: "Max"
5503
- };
5504
- var StreamingQuality = {
5505
- Auto: "Auto",
5506
- ...RecordingQuality
5507
- };
5508
- var STREAMING_QUALITY_TIERS = {
5509
- 0: "Auto",
5510
- ...RECORDING_QUALITY_TIERS
5511
- };
5512
- function resolveStreamingQuality(value) {
5513
- return STREAMING_QUALITY_TIERS[value];
5514
- }
5515
- function resolveStreamingQualityTier(value) {
5516
- if (typeof value === "boolean")
5517
- return void 0;
5518
- if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
5519
- const hit = Object.entries(STREAMING_QUALITY_TIERS).find(([, label]) => label.toLowerCase() === value.trim().toLowerCase());
5520
- return hit ? Number(hit[0]) : void 0;
5521
- }
5522
- const tier = Number(value);
5523
- return Number.isInteger(tier) && STREAMING_QUALITY_TIERS[tier] != null ? tier : void 0;
5524
- }
5525
- function resolveRecordingQuality(value) {
5526
- return RECORDING_QUALITY_TIERS[value];
5527
- }
5528
- function resolveRecordingQualityValue(name) {
5529
- const hit = Object.entries(RECORDING_QUALITY_TIERS).find(([, label]) => label.toLowerCase() === name.trim().toLowerCase());
5530
- return hit ? Number(hit[0]) : void 0;
5531
- }
5532
- function resolveRecordingQualityTier(value) {
5533
- if (typeof value === "boolean")
5534
- return void 0;
5535
- if (typeof value === "string" && !/^\d+$/.test(value.trim()))
5536
- return resolveRecordingQualityValue(value);
5537
- const tier = Number(value);
5538
- return Number.isInteger(tier) && RECORDING_QUALITY_TIERS[tier] != null ? tier : void 0;
5539
- }
5540
- function decodeRecordingQualityTier(raw) {
5541
- if (typeof raw === "number" || typeof raw === "string")
5542
- return resolveRecordingQualityTier(raw);
5543
- if (typeof raw !== "object" || raw === null)
5544
- return void 0;
5545
- const cfg = raw;
5546
- const mode = cfg[`mode_${Number(cfg.cur_mode) || 0}`];
5547
- const quality = typeof mode === "object" && mode !== null ? mode.quality : void 0;
5548
- return typeof quality === "number" ? resolveRecordingQualityTier(quality) : void 0;
5549
- }
5550
- function isEnableBitPolarity(ctx) {
5551
- const t = ctx.deviceType;
5552
- if (t === void 0)
5553
- return false;
5554
- if (t === DeviceType.INDOOR_CAMERA && ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE))
5555
- return false;
5556
- if (isIndoorCamera(ctx) && !isIndoorCamMini(ctx) && !isIndoorPanTiltS350(ctx))
5557
- return true;
5558
- return ENABLE_BIT_FLOODLIGHT_TYPES.has(t);
5559
- }
5560
- var ENABLE_BIT_FLOODLIGHT_TYPES = /* @__PURE__ */ new Set([
5561
- DeviceType.FLOODLIGHT_CAMERA_8422,
5562
- DeviceType.FLOODLIGHT_CAMERA_8424
5563
- ]);
5564
- function powerValue(on, ctx) {
5565
- return isEnableBitPolarity(ctx) ? on ? 1 : 0 : on ? 0 : 1;
5566
- }
5567
- function isT8410HomeBase3(ctx) {
5568
- return ctx.model === "T8410" && ctx.stationSerial?.startsWith("T8030") === true;
5569
- }
5570
- function isT8442(ctx) {
5571
- return ctx.model === "T8442";
5572
- }
5573
- function usesHomeBasePowerPayload(ctx) {
5574
- if (!isT8410HomeBase3(ctx) || !ctx.firmwareVersion || !/^\d+(?:\.\d+){2,3}$/.test(ctx.firmwareVersion))
5575
- return false;
5576
- const version = ctx.firmwareVersion.split(".").map(Number);
5577
- const minimum = [2, 3, 1, 0];
5578
- for (let i = 0; i < minimum.length; i++) {
5579
- const difference = (version[i] ?? 0) - minimum[i];
5580
- if (difference !== 0)
5581
- return difference > 0;
5582
- }
5583
- return true;
5584
- }
5585
- function hasUnreflectedHomeBasePower(ctx) {
5586
- return usesHomeBasePowerPayload(ctx) && !ctx.paramIds.has(2001);
5587
- }
5588
- function hasPrivacyPower(ctx) {
5589
- return ctx.deviceType === DeviceType.INDOOR_PT_CAMERA_S350 && ctx.homeBaseAttached === true;
5590
- }
5591
- function powerCommand(on, ctx) {
5592
- if (hasPrivacyPower(ctx))
5593
- return privacyCommand(!on, ctx.channel);
5594
- if (usesHomeBasePowerPayload(ctx))
5595
- return setPayload(CAMERA_CMD.PRIVACY_MODE, { switch: on ? 0 : 1 }, ctx, 0);
5596
- return setScalar(CAMERA_CMD.CAMERA_ENABLE, powerValue(on, ctx), ctx, "auto");
5597
- }
5598
- function privacyCommand(enabled, channel) {
5599
- return { kind: "p2p-privacy-burst", enabled, channel };
5600
- }
5601
- function statusLedCommand(on, ctx) {
5602
- if (hasCapability(ctx, "doorbell"))
5603
- return setPayload(CAMERA_CMD.DOORBELL_LED, { light_enable: on ? 1 : 0 }, ctx);
5604
- return setScalar(CAMERA_CMD.DEV_LED_SWITCH, on ? 1 : 0, ctx, "int-string");
5605
- }
5606
- function poweredOf(ctx) {
5607
- return cameraPowerTier(ctx.model, ctx.capabilities ?? /* @__PURE__ */ new Set());
5608
- }
5609
- function enablementReflection(on, ctx) {
5610
- if (!CAMERA_MEMBERS.enabled.readReflectsWrite(ctx))
5611
- return void 0;
5612
- const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
5613
- if (ctx.paramIds.has(alias))
5614
- return { param: alias, expected: on, observed: on };
5615
- if (ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE)) {
5616
- return {
5617
- param: CAMERA_CMD.CAMERA_ENABLE,
5618
- expected: powerValue(on, ctx),
5619
- observed: on
5620
- };
5621
- }
5622
- return void 0;
5623
- }
5624
- function refuseWhenDisabled(ctx, read) {
5625
- if (CAMERA_MEMBERS.enabled.readReflectsWrite(ctx) && read("enabled")?.value === false)
5626
- throw new CameraDisabledError(ctx.name ?? ctx.serial);
5627
- }
5628
- function refuseUnsupportedView(media, opts) {
5629
- const view = opts?.view ?? 0;
5630
- if (!(media.views ?? [0]).includes(view))
5631
- throw new RangeError(`camera: unsupported optical view ${view}`);
5632
- }
5633
- var CAMERA_MEMBERS = {
5634
- /** Reads the latest cached report and exposes no setter. */
5635
- wifiRssi: {
5636
- param: CAMERA_CMD.WIFI_RSSI,
5637
- type: "number",
5638
- kind: "scalar",
5639
- provenance: "apk",
5640
- description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
5641
- },
5642
- /**
5643
- * The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
5644
- * family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
5645
- * 1035, standalone indoor/outdoor cams under 2001 OPEN_DEVICE with direct polarity, so 2001 is a
5646
- * read-alias. Both verified live, and the write polarity is confirmed against the app's own frames.
5647
- *
5648
- * T8410 on HomeBase 3 reports 1035 as an enable bit ("1" ⇒ ON). That reported state does not reliably
5649
- * reflect the privacy command. `readReflectsWrite` states this distinction.
5650
- *
5651
- * The privacy param (6250) is reported by the outdoor-PT family and by no other camera measured, and both
5652
- * of its polarities are observed. It is deliberately NOT aliased here: it moved in the same step as 1035, so
5653
- * the reading cannot say whether power and privacy are one state or two — so
5654
- * aliasing a second param could only fold two possible states into one getter for no gain.
5655
- */
5656
- enabled: {
5657
- param: CAMERA_CMD.CAMERA_ENABLE,
5509
+ mechanicalChimeSwitch: {
5510
+ param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
5658
5511
  type: "bool",
5659
5512
  kind: "boolean",
5660
5513
  provenance: "verified",
5661
- available: isCameraCodec,
5662
- invert: true,
5663
- invertFor: (ctx) => isT8410HomeBase3(ctx) ? false : void 0,
5664
- readAliases: [{ paramType: 2001, invert: false }],
5665
- readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx) && !hasPrivacyPower(ctx),
5666
- description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (enable bit on T8410/HomeBase 3, disable bit on battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reported on/off state; unreflectedMembers identifies commands whose effect this report does not reliably reflect.",
5667
- observation: {
5668
- event: "cameraEnabledChanged",
5669
- reflects: (value, ctx) => enablementReflection(asBool(value), ctx),
5670
- timeoutMs: 2e4
5671
- },
5672
- write: (v, ctx) => powerCommand(asBool(v), ctx),
5673
- aliases: { on: true, off: false }
5514
+ description: `The existing wired chime box rings (1703; the app's "existing doorbell chime" switch \u2014 confirmed on T8214, and that pairing confirmed by toggling it alone on a T8210).`,
5515
+ write: (v, ctx) => setScalar(DOORBELL_CMD.MECHANICAL_CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
5674
5516
  },
5675
- /**
5676
- * A 180° rotation for a ceiling or upside-down mount, not a mirror — the app's own constant calls it
5677
- * `INDOOR_ROTATE_IMAGE` but it is not indoor-only. `apk` provenance: the wire is read out of the app
5678
- * parser and the write has not been driven on hardware, so treat a silent no-op as possible and
5679
- * confirm by re-reading rather than by trusting the dispatch.
5680
- */
5681
- imageFlipped: {
5682
- param: CAMERA_CMD.ROTATE_IMAGE,
5517
+ /** Same reasoning and the same capture session as {@link DOORBELL_MEMBERS.mechanicalChimeSwitch}. */
5518
+ wdrSwitch: {
5519
+ param: DOORBELL_CMD.WDR_SWITCH,
5683
5520
  type: "bool",
5684
5521
  kind: "boolean",
5685
- provenance: "apk",
5686
- available: isCameraCodec,
5687
- description: "Rotate the image 180\xB0 (ROTATE_IMAGE 1207): false = normal, true = flipped. For ceiling/upside-down mounts. Write is a direct param; wire from the app parser (unverified live).",
5688
- write: (v, ctx) => setScalar(CAMERA_CMD.ROTATE_IMAGE, asBool(v) ? 1 : 0, ctx, "auto")
5522
+ provenance: "verified",
5523
+ description: "Wide Dynamic Range (WDR) image switch (1704; write wire-confirmed live on T8214 \u2014 see DOORBELL_CMD.WDR_SWITCH).",
5524
+ write: (v, ctx) => setScalar(DOORBELL_CMD.WDR_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
5689
5525
  },
5690
5526
  /**
5691
- * A THREE-VALUE enum, not the boolean the name suggests — the middle option is timestamp without the
5692
- * logo, so a plain switch cannot express every state the device has. `coerceEnumValue` refuses
5693
- * anything outside {@link Watermark} instead of coercing it: on a fire-and-forget write a bogus index
5694
- * would dispatch and look like it worked. The labels are the app's radio order.
5527
+ * The READ half of a control whose write lives on `audio` — hence `writtenElsewhere`, which is what
5528
+ * keeps the published schema's `writable` true even though this member declares no `write`. The split
5529
+ * is deliberate: `audio` owns every volume wire, but 1708 leaks onto non-doorbell cameras, so `audio`
5530
+ * gates its setter on this capability while the read stays here where it is doorbell-only. Distinct
5531
+ * from `dingdongVolume` (1717), which is a different control on a different wire.
5695
5532
  */
5696
- watermark: {
5697
- param: CAMERA_CMD.SET_DEVS_OSD,
5698
- type: "enum",
5699
- kind: "enum",
5700
- enumValues: { 0: "Off", 1: "Timestamp", 2: "Timestamp + Logo" },
5533
+ ringtoneVolume: {
5534
+ param: 1708,
5535
+ type: "number",
5536
+ unit: "%",
5537
+ kind: "percent",
5701
5538
  provenance: "verified",
5702
- available: isCameraCodec,
5703
- description: "On-screen watermark/OSD overlay (CMD_SET_DEVS_OSD 1214). \u2705 wire verified live (T8425 ch3): direct-binary [ch][value 0/1/2] \u2014 a 3-value enum, not a bool. Enum labels are best-guess.",
5704
- write: (v, ctx) => {
5705
- const w = coerceEnumValue(Watermark, v);
5706
- return w == null ? void 0 : setScalar(CAMERA_CMD.SET_DEVS_OSD, w, ctx, "auto");
5707
- }
5539
+ writtenElsewhere: true,
5540
+ description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
5708
5541
  },
5709
5542
  /**
5710
- * Whether the camera triggers on what it HEARS — the sound counterpart to motion detection, which the
5711
- * app presents beside it. The camera keeps the sensitivity and the type it was last given, so
5712
- * switching this off and on again restores the previous configuration rather than resetting it.
5713
- */
5714
- soundDetection: {
5715
- param: CAMERA_CMD.SOUND_DETECTION,
5716
- type: "bool",
5717
- kind: "boolean",
5718
- provenance: "verified",
5719
- available: isCameraCodec,
5720
- description: "Trigger on sound as well as motion (SOUND_DETECTION 6043). \u2705 Write confirmed live on a T8410 (mains, standalone, own channel): both directions sent through this member and read back on the cloud param, and the app's own frame captured byte-exact for the same body.",
5721
- write: (v, ctx) => setJson(CAMERA_CMD.SOUND_DETECTION, { status: asBool(v) ? 1 : 0 }, ctx)
5722
- },
5723
- /**
5724
- * How loud a sound must be to trigger: `1` lowest, `3` mid, `5` highest, on the app's own
5725
- * three-position control. A `scalar` rather than an enum — the endpoints and the midpoint are
5726
- * observed, so 2 and 4 are a prediction of the scale's shape rather than values the wire is known to
5727
- * take. Independent of {@link CAMERA_MEMBERS.soundDetection}: the camera stores it whether sound
5728
- * detection is on or off.
5543
+ * A `1350` SET_PAYLOAD envelope (NOT direct-binary) with an explicit `mValue3` 0 — both live frames
5544
+ * captured that value. Bounded 0-100 like every sibling volume setter: the write is fire-and-forget,
5545
+ * so an out-of-range value would otherwise be sent as-is and look like it worked. `provenance` is
5546
+ * "verified" on our own capture, not merely an observed param id.
5729
5547
  */
5730
- soundDetectionSensitivity: {
5731
- param: CAMERA_CMD.SOUND_DETECTION_SENSITIVITY,
5548
+ dingdongVolume: {
5549
+ param: DOORBELL_CMD.DINGDONG_VOLUME,
5732
5550
  type: "number",
5733
- kind: "scalar",
5551
+ unit: "%",
5552
+ kind: "percent",
5734
5553
  provenance: "verified",
5735
- available: isCameraCodec,
5736
- description: "Sound-detection sensitivity: 1 = lowest, 3 = mid, 5 = highest (SOUND_DETECTION_SENSITIVITY 6044). \u2705 Write confirmed live on a T8410: 1 and 5 sent consecutively through this member and read back on the cloud param. 2 and 4 are the scale's shape, not observed values.",
5737
- write: (v, ctx) => {
5738
- const n = Number(v);
5739
- if (!Number.isInteger(n) || n < 1 || n > 5)
5740
- return void 0;
5741
- return setJson(CAMERA_CMD.SOUND_DETECTION_SENSITIVITY, { index: n }, ctx);
5742
- }
5554
+ min: 0,
5555
+ max: 100,
5556
+ description: "Doorbell chime volume, 0-100 (1717 APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME; SET_PAYLOAD envelope, distinct from the direct-binary ringtoneVolume/1708 \u2014 see DOORBELL_CMD.DINGDONG_VOLUME). Write wire-confirmed live on T8214, observed values 3 and 25.",
5557
+ write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0, void 0, "auto")
5743
5558
  },
5744
5559
  /**
5745
- * WHICH sounds count — any sound, or crying alone. `coerceEnumValue` refuses anything outside
5746
- * {@link SoundDetectionType} rather than coercing it: both neighbouring values are real types, so a
5747
- * coerced one arms the wrong trigger and reports success.
5560
+ * The same `1350` SET_PAYLOAD shape and capture session as {@link DOORBELL_MEMBERS.dingdongVolume},
5561
+ * but an INDEX rather than a level. Clamping a bad index would land on a real-but-wrong tone (garbage
5562
+ * → 0, too-big → 9) instead of failing, and only 3 of the 10 indices (0, 7, 8) are wire-tested — so
5563
+ * anything outside {@link DoorbellRingtone} is refused.
5748
5564
  */
5749
- soundDetectionType: {
5750
- param: CAMERA_CMD.SOUND_DETECTION_TYPE,
5751
- type: "enum",
5565
+ dingdongRingtone: {
5566
+ param: DOORBELL_CMD.DINGDONG_RINGTONE,
5567
+ type: "number",
5752
5568
  kind: "enum",
5753
- enumValues: { 1: "Crying", 2: "All Sound" },
5569
+ enumValues: enumLabels(DoorbellRingtone),
5754
5570
  provenance: "verified",
5755
- available: isCameraCodec,
5756
- description: "Which sounds trigger a detection: crying alone, or any sound (SOUND_DETECTION_TYPE 6046). \u2705 Write confirmed live on a T8410: both values sent consecutively through this member and read back on the cloud param.",
5571
+ args: [{ name: "ringtone", kind: "enum", description: "A tone INDEX, not a volume." }],
5572
+ description: "Doorbell chime ringtone SELECTION INDEX, not a bool (1718 APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE; SET_PAYLOAD envelope \u2014 see DOORBELL_CMD.DINGDONG_RINGTONE). Write wire-confirmed live on T8214, observed values 4 and 0. Named options: see the {@link DoorbellRingtone} enum. A write outside that enum is REJECTED, not clamped into range \u2014 this is a selection, not a volume, so a bad index would otherwise land on a real-but-wrong tone.",
5757
5573
  write: (v, ctx) => {
5758
- const type = coerceEnumValue(SoundDetectionType, v);
5759
- return type == null ? void 0 : setJson(CAMERA_CMD.SOUND_DETECTION_TYPE, { type }, ctx);
5574
+ const tone = coerceEnumValue(DoorbellRingtone, v);
5575
+ return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0, void 0, "auto");
5760
5576
  }
5761
5577
  },
5762
5578
  /**
5763
- * `coerceEnumValue` refuses anything outside {@link NotificationStyle} instead of coercing it: every
5764
- * neighbouring value is itself a real style, so a coerced one selects the wrong notification and
5765
- * reports success.
5766
- *
5767
- * `transaction` is part of the verified frame — a decimal epoch-in-milliseconds string, fresh per
5768
- * command.
5579
+ * The live-view quality half of {@link DOORBELL_CMD.VIDEO_QUALITY}, lifted out of the composite by
5580
+ * `decode`. `writtenElsewhere` because the setter needs the OTHER half's current value to rebuild the
5581
+ * integer, which a member's `write` — given only the new value and the context — cannot read.
5769
5582
  */
5770
- notificationStyle: {
5771
- param: CAMERA_CMD.PUSH_NOTIFY_TYPE,
5772
- type: "enum",
5583
+ videoQuality: {
5584
+ param: DOORBELL_CMD.VIDEO_QUALITY,
5585
+ type: "number",
5773
5586
  kind: "enum",
5774
- enumValues: { 1: "Text Only", 2: "Included Thumbnail", 3: "Text First, Then Thumbnail" },
5587
+ enumValues: enumLabels(DoorbellVideoQuality),
5775
5588
  provenance: "verified",
5776
- available: isCameraCodec,
5777
- description: "How much a detection push carries: text only, a thumbnail, or text then a thumbnail (CMD_INDOOR_PUSH_NOTIFY_TYPE 6020). All three values wire-verified live on a standalone T8171 over the cloud/WAN path \u2014 the 1700 control-payload wrapper, each one read back on the cloud param. A value outside the enum is REJECTED, not clamped: every neighbouring value is a real style, so a bad one would silently select the wrong notification.",
5778
- write: (v, ctx) => {
5779
- const style = coerceEnumValue(NotificationStyle2, v);
5780
- return style == null ? void 0 : setJson(CAMERA_CMD.PUSH_NOTIFY_TYPE, { value: style, transaction: String(Date.now()) }, ctx);
5781
- }
5589
+ writtenElsewhere: true,
5590
+ decode: (raw) => {
5591
+ const v = composite(raw);
5592
+ return v === void 0 ? void 0 : v % 5;
5593
+ },
5594
+ decodedKind: "enum",
5595
+ decodedValues: Object.values(DoorbellVideoQuality),
5596
+ description: "Live-view quality: 0 Auto / 1 Low / 2 Medium / 3 High (1705 CMD_BAT_DOORBELL_VIDEO_QUALITY). The parameter also carries the video encoding format, so this is the remainder mod 5 \u2014 see highCompressionEncoding for the other half. Ranks, not resolutions: the app's picker reads Auto/Low/Medium/High. Written by setVideoQuality, which takes the encoding format too \u2014 hardware-confirmed on a T8210."
5782
5597
  },
5783
5598
  /**
5784
- * Three modes, and `FullColor` is the conditional one: a model without a spotlight or starlight
5785
- * sensor omits it, and the wire accepts the value regardless — the device's own reported set is the
5786
- * only statement of which of the three it has. `coerceEnumValue` rejects a value outside
5787
- * {@link NightVision} rather than coercing it. The payload key on the wire is `night_sion`.
5599
+ * The encoding half of the SAME integer, so it declares no param of its own and reaches into the
5600
+ * owner's value — two members claiming 1705 would leave the second reading a name nothing is stored
5601
+ * under, and a paramless member publishes no schema entry at all, which is what keeps that from
5602
+ * happening structurally. High compression is the app's default on the captured device.
5788
5603
  */
5789
- nightVision: {
5790
- param: CAMERA_CMD.NIGHT_VISION_TYPE,
5791
- type: "enum",
5792
- kind: "enum",
5793
- enumValues: { 0: "Off", 1: "Infrared", 2: "Full Color" },
5794
- enumValuesFor: (ctx) => isT8442(ctx) ? { 0: "Off", 1: "Infrared" } : void 0,
5604
+ highCompressionEncoding: {
5605
+ readsFrom: "videoQuality",
5606
+ type: "bool",
5607
+ kind: "boolean",
5795
5608
  provenance: "verified",
5796
- available: isCameraCodec,
5797
- readAvailable: (ctx) => !isT8442(ctx),
5798
- readAliases: [{ paramType: 2002, available: isT8442 }],
5799
- description: "Night-vision mode: 0 = off, 1 = infrared (B&W), 2 = full colour where supported. T8425 uses NIGHT_VISION_TYPE 1277 (1350 SET_PAYLOAD, night_sion); T8442 reports 2002 and writes direct IC_NIGHT_VISION_TYPE 1013 (int-string, 0/1). Both wires verified live.",
5800
- write: (v, ctx) => {
5801
- const nv = coerceEnumValue(NightVision, v);
5802
- if (nv == null)
5803
- return void 0;
5804
- if (isT8442(ctx)) {
5805
- if (nv === NightVision.FullColor)
5806
- throw new Error("nightVision: Full Color is not supported on T8442 (only Off/Infrared)");
5807
- return setScalar(CAMERA_CMD.IC_NIGHT_VISION_TYPE, nv, ctx, "int-string");
5808
- }
5809
- return setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0, "auto");
5810
- }
5609
+ decode: (raw) => {
5610
+ const v = composite(raw);
5611
+ return v === void 0 ? void 0 : v >= 5;
5612
+ },
5613
+ description: "Video encoding format: true = high compression, false = low compression (the +5 offset inside 1705, which also carries videoQuality). Set through setVideoQuality, which takes both halves because the wire carries both."
5811
5614
  },
5812
5615
  /**
5813
- * Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
5814
- * different wire: changing one leaves the other's parameter untouched.
5815
- *
5816
- * The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
5817
- * `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
5818
- * carries the tier in force; the tier is what this property means. 1020 is also the name the param
5819
- * dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
5820
- * this name would collide with it on a device reporting both.
5616
+ * Set both halves of {@link DOORBELL_CMD.VIDEO_QUALITY} at once, because the wire has no way to set
5617
+ * one alone: every frame carries the whole integer.
5821
5618
  *
5822
- * `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
5823
- * confirmed frame carries no such field; neither is inferred from the other.
5619
+ * Taking both is not a convenience choice. An earlier shape took one and preserved the other by
5620
+ * reading it back, and it failed on hardware: the state reader is the snapshot the device was bound
5621
+ * with, so a second write in the same session composed against the value from before the first and
5622
+ * silently reverted it. Requiring both puts that decision where it can be made correctly — a caller
5623
+ * reads {@link DOORBELL_MEMBERS.videoQuality} and {@link DOORBELL_MEMBERS.highCompressionEncoding}
5624
+ * and passes what it wants, instead of the SDK guessing from a stale copy.
5824
5625
  */
5825
- streamingQuality: {
5826
- param: 1020,
5827
- type: "number",
5828
- kind: "enum",
5829
- enumValues: STREAMING_QUALITY_TIERS,
5830
- provenance: "verified",
5831
- available: isCameraCodec,
5832
- args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
5833
- ...accepts(),
5834
- description: "Live-view quality as a tier, 0 = Auto (1020; the write rides 2730 APP_CMD_MULTI_CAM_SET_VIDEO_QUALITY). Distinct from recordingQuality, which is what gets stored. All four tiers wire-verified live on a T8170 and read back on 1020. Sent from this SDK to a standalone T8170 and T8171, tier 1 holds the live stream at 1280x720 and tier 0 restores the camera's own resolution ladder \u2014 see CAMERA_CMD.STREAMING_QUALITY_SET. A value outside the tier set is REJECTED, not clamped: every tier in range is a real quality, so a bad one would silently pick another.",
5835
- write: (v, ctx) => {
5836
- const q = resolveStreamingQualityTier(v);
5837
- return q == null ? void 0 : setPayload(CAMERA_CMD.STREAMING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q, transaction: String(Date.now()) }, ctx, 0, void 0, "auto");
5626
+ setVideoQuality: method(({ ctx, sink }) => async (quality, highCompression) => {
5627
+ const q = coerceEnumValue(DoorbellVideoQuality, quality);
5628
+ if (q === void 0) {
5629
+ throw new Error(`videoQuality: ${String(quality)} is not one of ${enumLabels(DoorbellVideoQuality)}`);
5838
5630
  }
5839
- },
5631
+ await sink.dispatch(setScalar(DOORBELL_CMD.VIDEO_QUALITY, q + (asBool(highCompression) ? 5 : 0), ctx, "direct-binary"));
5632
+ }, "Set the live-view quality and the video encoding format, which share one wire."),
5840
5633
  /**
5841
- * `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
5842
- * of it by `decode`, so the getter answers a tier while the schema stays honest. The setter
5843
- * takes a resolution NAME as well as the tier the getter answers.
5634
+ * A config blob the device reports WHOLE, so it is typed `string` and handed back unparsed — three
5635
+ * settings live inside it (motion notifications, ring notifications, notification style) and the SDK
5636
+ * lifts none of them out, because no `decode` is evidenced for the shape. Read-only: no write frame
5637
+ * has been captured for it, and a config JSON is exactly where a guessed write does the most damage.
5844
5638
  */
5845
- recordingQuality: {
5846
- param: CAMERA_CMD.RECORDING_QUALITY_SET,
5639
+ notificationMode: {
5640
+ param: 1710,
5847
5641
  type: "string",
5642
+ kind: "text",
5848
5643
  provenance: "verified",
5849
- available: isCameraCodec,
5850
- decode: (raw) => decodeRecordingQualityTier(raw),
5851
- decodedKind: "enum",
5852
- decodedValues: Object.keys(RECORDING_QUALITY_TIERS).map(Number),
5853
- // Label the decoded tiers so a host can render this as a labelled choice (a dropdown) rather than
5854
- // a bare number — same shape as `suction` and the sibling `streamingQuality`. `decode` still owns
5855
- // the value (a tier); these are only the tier→label map, keyed to match `decodedValues`.
5856
- enumValues: RECORDING_QUALITY_TIERS,
5857
- description: "RECORDING quality as a tier (2731) \u2014 distinct from streamingQuality, which is the live view. The device reports the whole config \u2014 `{cur_mode, mode_<n>:{quality}}`, read live off a T8170 \u2014 and the ACTIVE mode's tier is lifted out of it on read. \u2705 write wire verified live (T8425): 1350 SET_PAYLOAD {channel:0, mode:0, primary_view:0, quality:N}. Tier\u2192label in RECORDING_QUALITY_TIERS / resolveRecordingQuality (verified on T8425; add a per-model map if a model's tiers ever diverge).",
5858
- args: [{ name: "quality", kind: "enum", description: "A tier; the resolution name it maps to is accepted too." }],
5859
- ...accepts(),
5860
- write: (v, ctx) => {
5861
- const q = resolveRecordingQualityTier(v);
5862
- return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0, void 0, "auto");
5863
- }
5644
+ description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
5864
5645
  },
5865
5646
  /**
5866
- * Not every model has the feature, and one without it accepts the frame without acting on it — so the
5867
- * write is offered only where the device reports 1015, the same param the read is gated on.
5647
+ * Play one of the doorbell's quick responses at the visitor. `voiceId` comes from
5648
+ * {@link DOORBELL_MEMBERS.quickResponses}. The doorbell only plays while it has an active media
5649
+ * session, so by default this briefly engages a live stream, sends, then tears it down.
5650
+ * `{ engage: false }` leaves an already-open stream alone.
5651
+ *
5652
+ * If engagement was requested but no stream comes up this THROWS, rather than firing 1706 into the
5653
+ * void and reporting success — the doorbell would play nothing. The wire is `QUICK_RESPONSE` 1706,
5654
+ * the SAME direct-binary command as camera on/off, carrying the voice id as its value.
5868
5655
  */
5869
- antiTheftDetection: {
5870
- param: CAMERA_CMD.EAS_SWITCH,
5871
- type: "bool",
5872
- kind: "boolean",
5873
- provenance: "apk",
5874
- available: isCameraCodec,
5875
- description: "Anti-theft detection on/off (1015 APP_CMD_EAS_SWITCH; the app parses it as anti_theft_detection_switch). Adaptive scalar {value:0|1}. \u26A0\uFE0F Replay+readback confirmed on a HomeBase-attached T8425 only; standalone unverified and v3 devices use id 2735 \u2014 see CAMERA_CMD.EAS_SWITCH.",
5876
- requires: [CAMERA_CMD.EAS_SWITCH],
5877
- write: (v, ctx) => setScalar(CAMERA_CMD.EAS_SWITCH, asBool(v) ? 1 : 0, ctx, "auto")
5878
- },
5656
+ playQuickResponse: method(({ ctx, sink, media }) => async (voiceId, opts = {}) => {
5657
+ const engage = opts.engage ?? true;
5658
+ let stream;
5659
+ if (engage) {
5660
+ if (!media) {
5661
+ throw new Error("playQuickResponse needs a live media session to engage, but the device is not bound to a live client \u2014 open a stream and call with { engage: false }");
5662
+ }
5663
+ try {
5664
+ stream = await media.live();
5665
+ } catch (e) {
5666
+ throw new Error(`playQuickResponse could not engage a live stream (the doorbell only plays while live): ${e instanceof Error ? e.message : String(e)}`);
5667
+ }
5668
+ await sleep(2500);
5669
+ }
5670
+ try {
5671
+ await sink.dispatch(setScalar(DOORBELL_CMD.QUICK_RESPONSE, voiceId, ctx, "direct-binary"));
5672
+ if (stream)
5673
+ await sleep(1500);
5674
+ } finally {
5675
+ try {
5676
+ stream?.stop();
5677
+ } catch {
5678
+ }
5679
+ }
5680
+ }, "Play a quick response at the visitor."),
5879
5681
  /**
5880
- * Privacy mode — the multi-frame burst. Nothing reports it back, so it is a setter with no getter, and
5881
- * it declares no param: the id the burst is built from is the transport's, not this capability's.
5682
+ * Fetch the doorbell's quick-response list — a P2P request/reply query that returns data, so it
5683
+ * exists only on a device bound to a media provider that offers one.
5882
5684
  *
5883
- * Being write-only, it is named by `unobservableMembers(dev.camera())`, which distinguishes "this camera
5884
- * is not in privacy mode" from "this camera cannot say" rather than leaving both as `undefined`. That
5885
- * distinction matters most on the families whose power rides this same envelope — see {@link enabled}.
5685
+ * The transport stays doorbell-agnostic: it runs a GENERIC SET_PAYLOAD query (sub-cmd → reply
5686
+ * payload); this capability owns the sub-command id (6237) and the reply parsing.
5886
5687
  */
5887
- privacy: {
5888
- type: "bool",
5889
- kind: "boolean",
5890
- writeOnly: true,
5891
- provenance: "verified",
5892
- available: isCameraCodec,
5893
- description: "Privacy mode (PRIVACY_MODE 6250) \u2014 the multi-frame burst the sink plays.",
5894
- write: (v, ctx) => privacyCommand(asBool(v), ctx.channel)
5895
- },
5688
+ quickResponses: provided("media", (m) => m.p2pQuery && (async (opts) => {
5689
+ const payload = await m.p2pQuery(DOORBELL_CMD.GET_QUICK_RESPONSE_LIST, opts);
5690
+ const voiceList = Array.isArray(payload?.voice_list) ? payload.voice_list : [];
5691
+ return parseQuickResponses(voiceList);
5692
+ }), "Fetch the doorbell's quick-response list.")
5693
+ };
5694
+ var DOORBELL = {
5695
+ capability: "doorbell",
5696
+ description: "Doorbell chime / ringtone configuration (ring press is an event, not a param).",
5697
+ members: DOORBELL_MEMBERS,
5698
+ properties: propertiesOf(DOORBELL_MEMBERS),
5896
5699
  /**
5897
- * The same status LED is reported under 1045 on ordinary cameras and 1716 on video doorbells. The
5898
- * alias is the same semantic value on a family-specific wire. The parameter valid for the resolved
5899
- * family is sufficient evidence to install both the getter and family-aware setter.
5700
+ * Doorbells self-report no param of their own (the 1702-1719 settings also arrive from eufyCams), and
5701
+ * the cloud record names no product, so detection keys off the app's own tables:
5702
+ * {@link DOORBELL_MODELS} and {@link DOORBELL_TYPES}.
5900
5703
  */
5901
- statusLed: {
5902
- param: CAMERA_CMD.DEV_LED_SWITCH,
5903
- type: "bool",
5904
- kind: "boolean",
5905
- provenance: "verified",
5906
- available: isCameraCodec,
5907
- readAvailable: (ctx) => !hasCapability(ctx, "doorbell"),
5908
- readAliases: [{ paramType: CAMERA_CMD.DOORBELL_LED, available: (ctx) => hasCapability(ctx, "doorbell") }],
5909
- requiresRead: true,
5910
- description: "Camera status LED. Video doorbells report the same state under their button-ring LED parameter.",
5911
- write: (v, ctx) => statusLedCommand(asBool(v), ctx)
5704
+ detection: {
5705
+ detect: (rec) => DOORBELL_MODELS.has(rec.model ?? ""),
5706
+ deviceTypes: DOORBELL_TYPES,
5707
+ modelHints: [/doorbell/i]
5912
5708
  },
5913
- views: provided("media", (m) => m.views && (() => m.views), "Optical view identifiers supported by this camera's media route."),
5914
5709
  /**
5915
- * Media is not a property: it returns DATA rather than moving state, its options are richer than a
5916
- * value, and it exists only on a device bound to a provider. Each is declared once, with its
5917
- * signature taken FROM {@link MediaProvider} — so a change there is a compile error here, not a drift.
5710
+ * Inbound FCM doorbell events (`DoorbellPushEvent`): ring press and the package trio.
5918
5711
  *
5919
- * Every pull is refused where a reliable {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
5920
- * `live`, `snapshotLive`, `record`, `openReadable` and `recordFragments` alike, since each opens media on a
5921
- * camera that serves none. That reading is the on/off source; a live probe is not one, since a disabled
5922
- * camera answers a start with audio and never a video frame.
5712
+ * Pet (3106) is NOT here. The id is declared identically in the doorbell, indoor and HB3-paired
5713
+ * vocabularies, so it belongs to the camera-wide `motion` module that every camera binds; claiming
5714
+ * it here as well would make it a contested id that a doorbell — which has both capabilities —
5715
+ * matches twice, emitting one push as two events.
5716
+ */
5717
+ events: [
5718
+ { source: "push", match: DoorbellPushEvent.PRESS_DOORBELL, emit: "doorbellPress" },
5719
+ { source: "push", match: DoorbellPushEvent.PACKAGE_DELIVERED, emit: "packageDelivered" },
5720
+ { source: "push", match: DoorbellPushEvent.PACKAGE_TAKEN, emit: "packageTaken" },
5721
+ { source: "push", match: DoorbellPushEvent.PACKAGE_STRANDED, emit: "packageStranded" }
5722
+ ]
5723
+ };
5724
+
5725
+ // dist/model/capabilities/camera.js
5726
+ var CAMERA_CMD = {
5727
+ /** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
5728
+ WIFI_RSSI: 1142,
5729
+ /** Camera on/off. Read and write polarity depend on the family and topology. */
5730
+ CAMERA_ENABLE: 1035,
5731
+ /** Wrapped T8410 power switch on HomeBase 3. */
5732
+ PRIVACY_MODE: 6250,
5733
+ /**
5734
+ * Camera status LED on/off — the "power/recording" indicator. The
5735
+ * app JS has a sibling param `1056` (`APP_CMD_LIVEVIEW_LED_SWITCH`) for the SAME UI "Status Light"
5736
+ * setting on some other model/generation — confirmed real (a full parser class exists) but tested
5737
+ * live with no effect on a T8425 (which uses 1045, shipped here); which model actually uses 1056
5738
+ * is unconfirmed. Don't wire 1056 as an alias of this without per-model evidence.
5739
+ */
5740
+ DEV_LED_SWITCH: 1045,
5741
+ /**
5742
+ * Rotate the image 180° on/off. Direct param, value 0 = normal, 1 = flipped. The app's constant is
5743
+ * `INDOOR_ROTATE_IMAGE`, but it is NOT indoor-only — ✅ verified live on an OUTDOOR floodlight cam
5744
+ * (T8425), so we drop the misleading "indoor". App parser: `{cmd:1207, params:{enable:0|1}}`.
5745
+ */
5746
+ ROTATE_IMAGE: 1207,
5747
+ /**
5748
+ * On-screen watermark / OSD overlay (app `CMD_SET_DEVS_OSD`). ✅ Wire verified live on T8425 (ch3):
5749
+ * a **3-value enum**, not a bool — 0 = off, 1 = timestamp, 2 = timestamp + logo. Direct-binary
5750
+ * `[channel][value]`. (Labels/enum in {@link Watermark}.)
5751
+ */
5752
+ SET_DEVS_OSD: 1214,
5753
+ /**
5754
+ * Video-doorbell status-LED on/off. Rides the `1350` SET_PAYLOAD envelope
5755
+ * (`{account_id,cmd:1716,mChannel,mValue3:1716,payload:{light_enable:0|1}}`),
5756
+ * signCode 8. ✅ verified live on Doorbell Dual T8214. Family-specific wire for the same semantic
5757
+ * status LED setting ordinary cameras report under 1045.
5758
+ */
5759
+ DOORBELL_LED: 1716,
5760
+ /**
5761
+ * Night-vision mode (app `NIGHT_VISION_TYPE`). Enum: 0 = off, 1 = infrared/auto (B&W), 2 = full colour.
5762
+ * ✅ All three verified live on T8425: `1350` SET_PAYLOAD, inner cmd 1277, `payload:{channel:<deviceCh>,
5763
+ * night_sion:N}`, mChannel 0, mValue3 0. Standalone (SINGLE-connect) uses direct `IC_NIGHT_VISION_TYPE`
5764
+ * (1013). (Some models omit full colour; enum in {@link NightVision}.)
5765
+ */
5766
+ NIGHT_VISION_TYPE: 1277,
5767
+ /** T8442 infrared night vision on/off: direct L1 int-string, hardware verified (0/1). */
5768
+ IC_NIGHT_VISION_TYPE: 1013,
5769
+ /**
5770
+ * Push-notification STYLE — how much a detection push carries: text alone, a thumbnail, or text
5771
+ * followed by a thumbnail. App `CMD_INDOOR_PUSH_NOTIFY_TYPE`, and the "indoor" is a misnomer: the
5772
+ * wire is confirmed on an OUTDOOR standalone camera (T8171). The app's id→name table carries TWO
5773
+ * aliases for this id, `ENTER_OTA` and `INDOOR_PUSH_NOTIFY_TYPE`; the confirmed behaviour is the
5774
+ * notification style.
5923
5775
  *
5924
- * {@link CAMERA_MEMBERS.snapshotStored} is exempt — a retained push thumbnail is not a pull. A reading of
5925
- * `undefined` refuses nothing: families reporting neither wire param leave the state unknown, and unknown is
5926
- * not known-off.
5776
+ * Wire verified live on a T8171 (standalone, own channel, reached over the cloud/WAN path): the
5777
+ * `1700` CONTROL_PAYLOAD wrapper, signCode 8, plaintext
5778
+ * `{"commandType":6020,"data":{"value":N,"transaction":"<epoch ms>"}}`. All three values captured
5779
+ * byte-exact, each read back on the cloud param within ~10s. The station answers on the same wrapper
5780
+ * ~600ms later with a 4-byte body — the command's int32 result — so this write is not
5781
+ * fire-and-forget, unlike the direct-binary switches.
5782
+ * (Values in {@link NotificationStyle}.)
5783
+ */
5784
+ PUSH_NOTIFY_TYPE: 6020,
5785
+ /**
5786
+ * **Sound detection** switch — whether the camera triggers on what it hears, beside the motion
5787
+ * trigger it triggers on what it sees.
5927
5788
  *
5928
- * The refusal REJECTS on the four that answer with a promise, and THROWS on
5929
- * {@link CAMERA_MEMBERS.recordFragments}, which answers with a handle.
5789
+ * Wire observed live on an indoor pan-tilt (standalone, mains, own channel): the `1700`
5790
+ * CONTROL_PAYLOAD wrapper, plaintext `{"commandType":6043,"data":{"status":0|1}}`. Both directions
5791
+ * captured byte-exact and read back on the cloud param, twice each; no other parameter moved.
5930
5792
  */
5931
- snapshotStored: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.snapshotStored && (() => m.snapshotStored()), "Latest validated push thumbnail retained in memory.", ["snapshot"]),
5932
- snapshotLive: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (opts) => {
5933
- refuseWhenDisabled(ctx, read);
5934
- refuseUnsupportedView(m, opts);
5935
- return m.snapshotLive({ powered: poweredOf(ctx), ...opts });
5936
- }), "Fresh still decoded from a short live burst."),
5937
- live: provided("media", (m, { ctx, read }) => async (opts) => {
5938
- refuseWhenDisabled(ctx, read);
5939
- refuseUnsupportedView(m, opts);
5940
- return m.live({ powered: poweredOf(ctx), ...opts });
5941
- }, "Open a managed live stream."),
5942
- record: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (...args) => {
5943
- refuseWhenDisabled(ctx, read);
5944
- return m.record(...args);
5945
- }), "Record N seconds \u2192 an mp4/h264 buffer."),
5946
- openReadable: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && m.openReadable && (async (opts) => {
5947
- refuseWhenDisabled(ctx, read);
5948
- refuseUnsupportedView(m, opts);
5949
- return m.openReadable({ powered: poweredOf(ctx), ...opts });
5950
- }), "Open a node:stream Readable of the live feed."),
5951
- recordFragments: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && m.recordFragments && ((opts) => {
5952
- refuseWhenDisabled(ctx, read);
5953
- return m.recordFragments({ powered: poweredOf(ctx), ...opts });
5954
- }), "Continuous fragmented-MP4 (CMAF) recording."),
5955
- downloadRecording: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
5793
+ SOUND_DETECTION: 6043,
5956
5794
  /**
5957
- * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
5958
- * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
5959
- * device never reported. Talkback holds a media session open for its duration, so it carries the same
5960
- * power hint `live` does; without it a battery camera talked to with no stream running would stream
5961
- * unbounded.
5795
+ * How loud a sound has to be to trigger {@link CAMERA_CMD.SOUND_DETECTION} — the app's three-position
5796
+ * control writes `1` lowest, `3` mid, `5` highest.
5797
+ *
5798
+ * Wire observed live on the same camera: `{"commandType":6044,"data":{"index":N}}` in the `1700`
5799
+ * wrapper. The payload field is `index`, NOT the `value` its neighbours use — each of these
5800
+ * commands names its field differently, so there is no generic setter for the family.
5962
5801
  */
5963
- talkback: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.talkback && ctx.paramIds.has(AUDIO_CMD.AUDIO_SPEAKER) && ((opts) => m.talkback({ powered: poweredOf(ctx), ...opts })), "Push audio from the host to the camera's speaker.")
5802
+ SOUND_DETECTION_SENSITIVITY: 6044,
5803
+ /**
5804
+ * WHICH sounds trigger {@link CAMERA_CMD.SOUND_DETECTION} — all sound, or crying alone.
5805
+ *
5806
+ * Wire observed live: `{"commandType":6046,"data":{"type":N}}` in the `1700` wrapper, both values
5807
+ * captured and read back. (Values in {@link SoundDetectionType}.)
5808
+ */
5809
+ SOUND_DETECTION_TYPE: 6046,
5810
+ /**
5811
+ * **Anti-theft detection** switch (app `APP_CMD_EAS_SWITCH`). Despite the "EAS" name the app's own
5812
+ * parser maps this id onto `anti_theft_detection_switch`, so the camera member uses that semantic
5813
+ * name. The app's "EAS" resource strings mix emergency- and anti-theft-worded copy; the parser is
5814
+ * the tiebreak.
5815
+ *
5816
+ * Wire from the app's own JS: a scalar `params:{value:0|1}`. Emitted with the **adaptive** form so
5817
+ * topology picks the level — level-2/direct on a HomeBase-attached device, level-1 on a standalone.
5818
+ *
5819
+ * ⚠️ Replay + readback confirmed on a **HomeBase-attached** T8425 only (1015 read `0` → write → `1`
5820
+ * → restore): the frame is confirmed accepted and persisted, NOT byte-compared to the app's. The
5821
+ * **standalone** path is unverified, and newer (v3) devices use a *different* id for the same
5822
+ * switch — `APP_CMD_NEW_EAS_SWITCH` (2735) — which has no path here; don't assume 1015 drives them.
5823
+ */
5824
+ EAS_SWITCH: 1015,
5825
+ /**
5826
+ * RECORDING quality — what gets stored, not the live view; {@link CAMERA_CMD.STREAMING_QUALITY_SET}
5827
+ * below is the live one, and the app's name for this id is `multicamSetRecordQuailty`.
5828
+ * Wire confirmed on a T8425 ch3: `1350` SET_PAYLOAD, inner cmd 2731,
5829
+ * `payload:{channel:0, mode:0, primary_view:0, quality:N}`, mValue3 0, on the device channel. Tiers
5830
+ * 1/2/3, named in {@link RECORDING_QUALITY_TIERS} — no tier 0 on this wire, where streaming has one.
5831
+ * That is a fact about 2731 on the cameras it was confirmed on, not about recording everywhere. A
5832
+ * doorbell has no recording-quality setting at all, and its live-view quality rides neither 2730 nor
5833
+ * 2731 but 1705, on a domain of its own (5 = Auto, 6/7/8 = Low/Medium/High, all four observed).
5834
+ */
5835
+ RECORDING_QUALITY_SET: 2731,
5836
+ /**
5837
+ * LIVE-VIEW quality — a separate setting from {@link CAMERA_CMD.RECORDING_QUALITY_SET}, and the app's
5838
+ * names for the two invert what they suggest: 2730 is `multicamSetVideoQuailty` and drives the
5839
+ * Streaming Quality picker, while 2731 is `multicamSetRecordQuailty` and drives Recording Quality.
5840
+ * Changing one leaves the other's parameter untouched, which is how they were told apart.
5841
+ *
5842
+ * Unlike recording it offers a tier 0, `Auto`. Wire verified live on a T8170 (standalone): the `1350`
5843
+ * SET_PAYLOAD envelope, signCode 8, plaintext `{"cmd":2730,"payload":{"transaction":"<epoch ms>",
5844
+ * "quality":N,"channel":0,"mode":0,"primary_view":0},"account_id":…}`. All four tiers captured
5845
+ * byte-exact and read back on parameter 1020; the station echoes the same payload back as a `1351`
5846
+ * NOTIFY_PAYLOAD carrying the same `transaction`.
5847
+ */
5848
+ STREAMING_QUALITY_SET: 2730
5964
5849
  };
5965
- var CAMERA = {
5966
- capability: "camera",
5967
- description: "Camera power (on/off, CAMERA_SWITCH 1035) and privacy mode (PRIVACY_MODE 6250).",
5968
- members: CAMERA_MEMBERS,
5969
- properties: propertiesOf(CAMERA_MEMBERS),
5970
- /** Every camera-codec device has the power/privacy surface. */
5971
- detection: { codecs: ["camera"] },
5972
- /** Only the no-argument power verbs, which carry no value for a member to hold. */
5973
- actions({ ctx, sink }) {
5974
- if (!isCameraCodec(ctx))
5975
- return {};
5976
- return {
5977
- on: () => sink.dispatch(powerCommand(true, ctx)),
5978
- off: () => sink.dispatch(powerCommand(false, ctx))
5979
- };
5980
- }
5850
+ function hasReportedEasSwitch(source) {
5851
+ return source.paramIds?.has(CAMERA_CMD.EAS_SWITCH) === true || Object.hasOwn(source.params ?? {}, CAMERA_CMD.EAS_SWITCH);
5852
+ }
5853
+ var Watermark = {
5854
+ /** No timestamp or logo. */
5855
+ Off: 0,
5856
+ /** Timestamp only. */
5857
+ Timestamp: 1,
5858
+ /** Timestamp + eufy logo. */
5859
+ TimestampAndLogo: 2
5981
5860
  };
5982
-
5983
- // dist/model/capabilities/siren.js
5984
- var SIREN_PARAM = {
5985
- /** Whether the siren is sounding (app `APP_CMD_DEV_RING_STATUS`). 1 = sounding, 0 = silent. */
5986
- RING_STATUS: 61008,
5987
- /** Alarm volume as a device level (app `APP_CMD_SIREN_SENSOR_SET_ALARM_VOL`). See {@link SirenVolume}. */
5988
- ALARM_VOLUME: 1825,
5989
- /** Seconds an alarm sounds before stopping itself (app `APP_CMD_DEV_ALARM_TIMEOUT`). */
5990
- ALARM_TIMEOUT: 61006,
5991
- /** Do-not-disturb (app `APP_CMD_SENSOR_NOT_DISTURB`). */
5992
- NOT_DISTURB: 1828
5861
+ var NotificationStyle2 = {
5862
+ /** The push carries text alone. */
5863
+ TextOnly: 1,
5864
+ /** The push carries a thumbnail of the detection. */
5865
+ IncludedThumbnail: 2,
5866
+ /** The push arrives as text, then updates with a thumbnail. */
5867
+ TextFirstThenThumbnail: 3
5993
5868
  };
5994
- var SIREN_CMD = {
5995
- /** Sound the siren briefly as a test (app `APP_CMD_SIREN_SENSOR_ALARM_TEST`). */
5996
- ALARM_TEST: 1826,
5997
- /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
5998
- MANUAL_STOP: 1871,
5999
- /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
6000
- HOMEBASE_TONE: 1201,
6001
- /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
6002
- HUB_SPK_VOLUME: 1235,
6003
- /** HomeBase alarm-tone selection (app `APP_CMD_HUB_ALARM_TONE`). */
6004
- HUB_ALARM_TONE: 1281,
6005
- /** Attached-camera duration alarm, verified live on T8114 and T8210 (app `SET_DEVS_TONE_FILE`). */
6006
- CAMERA_TONE: 1202
5869
+ var SoundDetectionType = {
5870
+ /** Crying alone triggers a detection. */
5871
+ Crying: 1,
5872
+ /** Any sound loud enough for the sensitivity triggers a detection. */
5873
+ AllSound: 2
6007
5874
  };
6008
- var STATION_CHANNEL = 255;
6009
- var SirenVolume = { Low: 1, Mid: 2, High: 3 };
6010
- var SirenAlarmDuration = { Min1: 60, Min5: 300, Min10: 600, Min15: 900 };
6011
- var HubAlarmTone = { Tone1: 1, Tone2: 2 };
6012
- var HUB_ALARM_EVIDENCE = [SIREN_CMD.HUB_ALARM_TONE, 1282];
6013
- function isStationAlarmOutput(ctx) {
6014
- return ctx.deviceType === DeviceType.STATION || ctx.deviceType === DeviceType.HB3;
5875
+ var NightVision = {
5876
+ /** Off — never use infrared. */
5877
+ Off: 0,
5878
+ /** Infrared / "B&W Auto" — black-and-white night vision. */
5879
+ Infrared: 1,
5880
+ /** Full colour night vision (models with a spotlight / starlight sensor). */
5881
+ FullColor: 2
5882
+ };
5883
+ var RecordingQuality = {
5884
+ HD720: "HD (720P)",
5885
+ FullHD1080: "Full HD (1080P)",
5886
+ Max: "Max"
5887
+ };
5888
+ var RECORDING_QUALITY_TIERS = {
5889
+ 1: "HD (720P)",
5890
+ 2: "Full HD (1080P)",
5891
+ 3: "Max"
5892
+ };
5893
+ var StreamingQuality = {
5894
+ Auto: "Auto",
5895
+ ...RecordingQuality
5896
+ };
5897
+ var STREAMING_QUALITY_TIERS = {
5898
+ 0: "Auto",
5899
+ ...RECORDING_QUALITY_TIERS
5900
+ };
5901
+ function resolveStreamingQuality(value) {
5902
+ return STREAMING_QUALITY_TIERS[value];
6015
5903
  }
6016
- function isEvidencedStationAlarm(ctx) {
6017
- return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
5904
+ function resolveStreamingQualityTier(value) {
5905
+ if (typeof value === "boolean")
5906
+ return void 0;
5907
+ if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
5908
+ const hit = Object.entries(STREAMING_QUALITY_TIERS).find(([, label]) => label.toLowerCase() === value.trim().toLowerCase());
5909
+ return hit ? Number(hit[0]) : void 0;
5910
+ }
5911
+ const tier = Number(value);
5912
+ return Number.isInteger(tier) && STREAMING_QUALITY_TIERS[tier] != null ? tier : void 0;
6018
5913
  }
6019
- var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set([
6020
- "T8134",
6021
- "T814X",
6022
- "T8214",
6023
- "T8790",
6024
- "T8122",
6025
- "T8113-Z",
6026
- "T8400"
6027
- ]);
6028
- function isEvidencedAttachedCamera(ctx) {
6029
- return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
5914
+ function resolveRecordingQuality(value) {
5915
+ return RECORDING_QUALITY_TIERS[value];
6030
5916
  }
6031
- function reportsParam(source, param) {
6032
- return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
5917
+ function resolveRecordingQualityValue(name) {
5918
+ const hit = Object.entries(RECORDING_QUALITY_TIERS).find(([, label]) => label.toLowerCase() === name.trim().toLowerCase());
5919
+ return hit ? Number(hit[0]) : void 0;
6033
5920
  }
6034
- function hasStandaloneSirenEvidence(source) {
6035
- return reportsParam(source, SIREN_PARAM.ALARM_VOLUME) && (reportsParam(source, SIREN_PARAM.RING_STATUS) || reportsParam(source, SIREN_PARAM.ALARM_TIMEOUT));
5921
+ function resolveRecordingQualityTier(value) {
5922
+ if (typeof value === "boolean")
5923
+ return void 0;
5924
+ if (typeof value === "string" && !/^\d+$/.test(value.trim()))
5925
+ return resolveRecordingQualityValue(value);
5926
+ const tier = Number(value);
5927
+ return Number.isInteger(tier) && RECORDING_QUALITY_TIERS[tier] != null ? tier : void 0;
6036
5928
  }
6037
- function isSensorCodec(ctx) {
6038
- return ctx.codec === "sensor";
5929
+ function decodeRecordingQualityTier(raw) {
5930
+ if (typeof raw === "number" || typeof raw === "string")
5931
+ return resolveRecordingQualityTier(raw);
5932
+ if (typeof raw !== "object" || raw === null)
5933
+ return void 0;
5934
+ const cfg = raw;
5935
+ const mode = cfg[`mode_${Number(cfg.cur_mode) || 0}`];
5936
+ const quality = typeof mode === "object" && mode !== null ? mode.quality : void 0;
5937
+ return typeof quality === "number" ? resolveRecordingQualityTier(quality) : void 0;
6039
5938
  }
6040
- function isEvidencedStandaloneSiren(ctx) {
6041
- return isSensorCodec(ctx) && hasStandaloneSirenEvidence(ctx);
5939
+ function isEnableBitPolarity(ctx) {
5940
+ const t = ctx.deviceType;
5941
+ if (t === void 0)
5942
+ return false;
5943
+ if (t === DeviceType.INDOOR_CAMERA && ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE))
5944
+ return false;
5945
+ if (isIndoorCamera(ctx) && !isIndoorCamMini(ctx) && !isIndoorPanTiltS350(ctx))
5946
+ return true;
5947
+ return ENABLE_BIT_FLOODLIGHT_TYPES.has(t);
6042
5948
  }
6043
- function homeBaseAlarm(seconds, ctx) {
6044
- if (!ctx.accountName)
6045
- throw new Error("station-family HomeBase alarm output requires the acting account name");
6046
- return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
5949
+ var ENABLE_BIT_FLOODLIGHT_TYPES = /* @__PURE__ */ new Set([
5950
+ DeviceType.FLOODLIGHT_CAMERA_8422,
5951
+ DeviceType.FLOODLIGHT_CAMERA_8424
5952
+ ]);
5953
+ function powerValue(on, ctx) {
5954
+ return isEnableBitPolarity(ctx) ? on ? 1 : 0 : on ? 0 : 1;
6047
5955
  }
6048
- var PORTAL_MANUAL_ALARM_TYPE = 10;
6049
- function cameraAlarm(seconds, ctx) {
6050
- if (isStation9000({ deviceType: ctx.stationDeviceType })) {
6051
- if (!ctx.accountName)
6052
- throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
6053
- return setPayload(SIREN_CMD.CAMERA_TONE, {
6054
- time_out: seconds,
6055
- type: PORTAL_MANUAL_ALARM_TYPE,
6056
- channel: ctx.channel,
6057
- username: ctx.accountName
6058
- }, ctx);
5956
+ function isT8410HomeBase3(ctx) {
5957
+ return ctx.model === "T8410" && ctx.stationSerial?.startsWith("T8030") === true;
5958
+ }
5959
+ function isT8442(ctx) {
5960
+ return ctx.model === "T8442";
5961
+ }
5962
+ function usesHomeBasePowerPayload(ctx) {
5963
+ if (!isT8410HomeBase3(ctx) || !ctx.firmwareVersion || !/^\d+(?:\.\d+){2,3}$/.test(ctx.firmwareVersion))
5964
+ return false;
5965
+ const version = ctx.firmwareVersion.split(".").map(Number);
5966
+ const minimum = [2, 3, 1, 0];
5967
+ for (let i = 0; i < minimum.length; i++) {
5968
+ const difference = (version[i] ?? 0) - minimum[i];
5969
+ if (difference !== 0)
5970
+ return difference > 0;
6059
5971
  }
6060
- return {
6061
- kind: "p2p-int-string",
6062
- cmd: SIREN_CMD.CAMERA_TONE,
6063
- value: seconds,
6064
- valueSub: ctx.channel,
6065
- channel: ctx.channel
6066
- };
5972
+ return true;
6067
5973
  }
6068
- function alarmOutputFamily(ctx) {
6069
- if (isEvidencedStationAlarm(ctx))
6070
- return "homebase";
6071
- if (isEvidencedAttachedCamera(ctx))
6072
- return "camera";
6073
- if (isEvidencedStandaloneSiren(ctx))
6074
- return "standalone";
6075
- return void 0;
5974
+ function hasUnreflectedHomeBasePower(ctx) {
5975
+ return usesHomeBasePowerPayload(ctx) && !ctx.paramIds.has(2001);
6076
5976
  }
6077
- function triggerAlarm(seconds, ctx) {
6078
- const family = alarmOutputFamily(ctx);
6079
- if (family === "homebase")
6080
- return homeBaseAlarm(seconds, ctx);
6081
- if (family === "camera")
6082
- return cameraAlarm(seconds, ctx);
6083
- throw new Error("manual alarm trigger has no verified wire for this device family");
5977
+ function hasPrivacyPower(ctx) {
5978
+ return ctx.deviceType === DeviceType.INDOOR_PT_CAMERA_S350 && ctx.homeBaseAttached === true;
6084
5979
  }
6085
- function stopAlarm(ctx) {
6086
- const family = alarmOutputFamily(ctx);
6087
- if (family === "homebase")
6088
- return homeBaseAlarm(0, ctx);
6089
- if (family === "camera")
6090
- return cameraAlarm(0, ctx);
6091
- if (family === "standalone")
6092
- return sirenPayload(SIREN_CMD.MANUAL_STOP, {}, ctx);
6093
- throw new Error("manual alarm stop has no verified wire for this device family");
5980
+ function powerCommand(on, ctx) {
5981
+ if (hasPrivacyPower(ctx))
5982
+ return privacyCommand(!on, ctx.channel);
5983
+ if (usesHomeBasePowerPayload(ctx))
5984
+ return setPayload(CAMERA_CMD.PRIVACY_MODE, { switch: on ? 0 : 1 }, ctx, 0);
5985
+ return setScalar(CAMERA_CMD.CAMERA_ENABLE, powerValue(on, ctx), ctx, "auto");
6094
5986
  }
6095
- function validateTriggerDuration(seconds) {
6096
- if (!Number.isSafeInteger(seconds) || seconds <= 0) {
6097
- throw new Error(`trigger duration ${String(seconds)} is not a positive safe whole-number duration in seconds`);
5987
+ function privacyCommand(enabled, channel) {
5988
+ return { kind: "p2p-privacy-burst", enabled, channel };
5989
+ }
5990
+ function statusLedCommand(on, ctx) {
5991
+ if (hasCapability(ctx, "doorbell"))
5992
+ return setPayload(CAMERA_CMD.DOORBELL_LED, { light_enable: on ? 1 : 0 }, ctx);
5993
+ return setScalar(CAMERA_CMD.DEV_LED_SWITCH, on ? 1 : 0, ctx, "int-string");
5994
+ }
5995
+ function poweredOf(ctx) {
5996
+ return cameraPowerTier(ctx.model, ctx.capabilities ?? /* @__PURE__ */ new Set());
5997
+ }
5998
+ function enablementReflection(on, ctx) {
5999
+ if (!CAMERA_MEMBERS.enabled.readReflectsWrite(ctx))
6000
+ return void 0;
6001
+ const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
6002
+ if (ctx.paramIds.has(alias))
6003
+ return { param: alias, expected: on, observed: on };
6004
+ if (ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE)) {
6005
+ return {
6006
+ param: CAMERA_CMD.CAMERA_ENABLE,
6007
+ expected: powerValue(on, ctx),
6008
+ observed: on
6009
+ };
6098
6010
  }
6099
- return seconds;
6011
+ return void 0;
6100
6012
  }
6101
- function sirenPayload(cmd, body, ctx) {
6102
- return setPayload(cmd, { ...body, transaction: String(Date.now()) }, ctx, 0);
6013
+ function refuseWhenDisabled(ctx, read) {
6014
+ if (CAMERA_MEMBERS.enabled.readReflectsWrite(ctx) && read("enabled")?.value === false)
6015
+ throw new CameraDisabledError(ctx.name ?? ctx.serial);
6103
6016
  }
6104
- var SIREN_MEMBERS = {
6105
- /**
6106
- * Authoritative sounding state reported by a standalone siren. HomeBase and camera command
6107
- * acknowledgements do not install or update this read.
6108
- */
6109
- active: {
6110
- param: SIREN_PARAM.RING_STATUS,
6111
- property: "siren",
6112
- type: "bool",
6113
- kind: "boolean",
6114
- provenance: "verified",
6115
- available: isSensorCodec,
6116
- description: "Whether the siren is sounding (61008 APP_CMD_DEV_RING_STATUS). \u2705 Confirmed boolean: a live test on a T90R0 pushed 1 (sounding) then 0 (silent)."
6117
- },
6118
- /**
6119
- * Rejected, not clamped, outside the 1-3 set: the write is fire-and-forget, so an out-of-range level
6120
- * would look like it worked.
6121
- */
6122
- volume: {
6123
- param: SIREN_PARAM.ALARM_VOLUME,
6124
- property: "sirenVolume",
6017
+ function refuseUnsupportedView(media, opts) {
6018
+ const view = opts?.view ?? 0;
6019
+ if (!(media.views ?? [0]).includes(view))
6020
+ throw new RangeError(`camera: unsupported optical view ${view}`);
6021
+ }
6022
+ var CAMERA_MEMBERS = {
6023
+ /** Reads the latest cached report and exposes no setter. */
6024
+ wifiRssi: {
6025
+ param: CAMERA_CMD.WIFI_RSSI,
6125
6026
  type: "number",
6126
6027
  kind: "scalar",
6127
- provenance: "verified",
6128
- available: isSensorCodec,
6129
- requires: [SIREN_PARAM.ALARM_VOLUME],
6130
- enumValues: enumLabels(SirenVolume),
6131
- args: [{ name: "level", kind: "scalar", description: "A device level 1-3 (Low/Mid/High), not a percentage." }],
6132
- description: "Alarm volume (1825 APP_CMD_SIREN_SENSOR_SET_ALARM_VOL), a device level 1-3 (Low/Mid/High), NOT a percentage. Write verified live on a T90R0 (1350 SET_PAYLOAD {volume}).",
6133
- write: (v, ctx) => {
6134
- const level = coerceEnumValue(SirenVolume, v);
6135
- return level === void 0 ? void 0 : sirenPayload(SIREN_PARAM.ALARM_VOLUME, { volume: level }, ctx);
6136
- }
6028
+ provenance: "apk",
6029
+ description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
6137
6030
  },
6138
- /** Only the four captured presets are accepted: no capture supports an arbitrary duration. */
6139
- alarmDuration: {
6140
- param: SIREN_PARAM.ALARM_TIMEOUT,
6141
- type: "number",
6142
- unit: "s",
6143
- kind: "seconds",
6031
+ /**
6032
+ * The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
6033
+ * family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
6034
+ * 1035, standalone indoor/outdoor cams under 2001 OPEN_DEVICE with direct polarity, so 2001 is a
6035
+ * read-alias. Both verified live, and the write polarity is confirmed against the app's own frames.
6036
+ *
6037
+ * T8410 on HomeBase 3 reports 1035 as an enable bit ("1" ⇒ ON). That reported state does not reliably
6038
+ * reflect the privacy command. `readReflectsWrite` states this distinction.
6039
+ *
6040
+ * The privacy param (6250) is reported by the outdoor-PT family and by no other camera measured, and both
6041
+ * of its polarities are observed. It is deliberately NOT aliased here: it moved in the same step as 1035, so
6042
+ * the reading cannot say whether power and privacy are one state or two — so
6043
+ * aliasing a second param could only fold two possible states into one getter for no gain.
6044
+ */
6045
+ enabled: {
6046
+ param: CAMERA_CMD.CAMERA_ENABLE,
6047
+ type: "bool",
6048
+ kind: "boolean",
6144
6049
  provenance: "verified",
6145
- available: isSensorCodec,
6146
- requires: [SIREN_PARAM.ALARM_TIMEOUT],
6147
- enumValues: enumLabels(SirenAlarmDuration),
6148
- args: [{ name: "seconds", kind: "seconds", description: "One of the app's presets: 60/300/600/900." }],
6149
- description: "How long an alarm sounds before stopping itself (61006 APP_CMD_DEV_ALARM_TIMEOUT), seconds. Write verified live on a T90R0 (1350 SET_PAYLOAD {value}); app presets 60/300/600/900.",
6050
+ available: isCameraCodec,
6051
+ invert: true,
6052
+ invertFor: (ctx) => isT8410HomeBase3(ctx) ? false : void 0,
6053
+ readAliases: [{ paramType: 2001, invert: false }],
6054
+ readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx) && !hasPrivacyPower(ctx),
6055
+ description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (enable bit on T8410/HomeBase 3, disable bit on battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reported on/off state; unreflectedMembers identifies commands whose effect this report does not reliably reflect.",
6056
+ observation: {
6057
+ event: "cameraEnabledChanged",
6058
+ reflects: (value, ctx) => enablementReflection(asBool(value), ctx),
6059
+ timeoutMs: 2e4
6060
+ },
6061
+ write: (v, ctx) => powerCommand(asBool(v), ctx),
6062
+ aliases: { on: true, off: false }
6063
+ },
6064
+ /**
6065
+ * A 180° rotation for a ceiling or upside-down mount, not a mirror — the app's own constant calls it
6066
+ * `INDOOR_ROTATE_IMAGE` but it is not indoor-only. `apk` provenance: the wire is read out of the app
6067
+ * parser and the write has not been driven on hardware, so treat a silent no-op as possible and
6068
+ * confirm by re-reading rather than by trusting the dispatch.
6069
+ */
6070
+ imageFlipped: {
6071
+ param: CAMERA_CMD.ROTATE_IMAGE,
6072
+ type: "bool",
6073
+ kind: "boolean",
6074
+ provenance: "apk",
6075
+ available: isCameraCodec,
6076
+ description: "Rotate the image 180\xB0 (ROTATE_IMAGE 1207): false = normal, true = flipped. For ceiling/upside-down mounts. Write is a direct param; wire from the app parser (unverified live).",
6077
+ write: (v, ctx) => setScalar(CAMERA_CMD.ROTATE_IMAGE, asBool(v) ? 1 : 0, ctx, "auto")
6078
+ },
6079
+ /**
6080
+ * A THREE-VALUE enum, not the boolean the name suggests — the middle option is timestamp without the
6081
+ * logo, so a plain switch cannot express every state the device has. `coerceEnumValue` refuses
6082
+ * anything outside {@link Watermark} instead of coercing it: on a fire-and-forget write a bogus index
6083
+ * would dispatch and look like it worked. The labels are the app's radio order.
6084
+ */
6085
+ watermark: {
6086
+ param: CAMERA_CMD.SET_DEVS_OSD,
6087
+ type: "enum",
6088
+ kind: "enum",
6089
+ enumValues: { 0: "Off", 1: "Timestamp", 2: "Timestamp + Logo" },
6090
+ provenance: "verified",
6091
+ available: isCameraCodec,
6092
+ description: "On-screen watermark/OSD overlay (CMD_SET_DEVS_OSD 1214). \u2705 wire verified live (T8425 ch3): direct-binary [ch][value 0/1/2] \u2014 a 3-value enum, not a bool. Enum labels are best-guess.",
6150
6093
  write: (v, ctx) => {
6151
- const seconds = coerceEnumValue(SirenAlarmDuration, v);
6152
- return seconds === void 0 ? void 0 : sirenPayload(SIREN_PARAM.ALARM_TIMEOUT, { value: seconds }, ctx);
6094
+ const w = coerceEnumValue(Watermark, v);
6095
+ return w == null ? void 0 : setScalar(CAMERA_CMD.SET_DEVS_OSD, w, ctx, "auto");
6153
6096
  }
6154
6097
  },
6155
6098
  /**
6156
- * Read-only: `apk` provenance means the id comes from the disassembled app and the only live evidence
6157
- * is a T90R0 reporting 0, which fixes neither the polarity's other value nor a write frame. Typed
6158
- * `bool` on the app's own naming; do not add a setter until a toggle is captured.
6099
+ * Whether the camera triggers on what it HEARS — the sound counterpart to motion detection, which the
6100
+ * app presents beside it. The camera keeps the sensitivity and the type it was last given, so
6101
+ * switching this off and on again restores the previous configuration rather than resetting it.
6159
6102
  */
6160
- doNotDisturb: {
6161
- param: SIREN_PARAM.NOT_DISTURB,
6103
+ soundDetection: {
6104
+ param: CAMERA_CMD.SOUND_DETECTION,
6162
6105
  type: "bool",
6163
6106
  kind: "boolean",
6164
- provenance: "apk",
6165
- available: isSensorCodec,
6166
- description: "Do-not-disturb (1828 APP_CMD_SENSOR_NOT_DISTURB). Observed 0 on a T90R0."
6107
+ provenance: "verified",
6108
+ available: isCameraCodec,
6109
+ description: "Trigger on sound as well as motion (SOUND_DETECTION 6043). \u2705 Write confirmed live on a T8410 (mains, standalone, own channel): both directions sent through this member and read back on the cloud param, and the app's own frame captured byte-exact for the same body.",
6110
+ write: (v, ctx) => setJson(CAMERA_CMD.SOUND_DETECTION, { status: asBool(v) ? 1 : 0 }, ctx)
6167
6111
  },
6168
- /** HomeBase alarm volume is a percentage on a station-scalar wire, unlike standalone level 1-3. */
6169
- alarmVolume: {
6170
- param: SIREN_CMD.HUB_SPK_VOLUME,
6112
+ /**
6113
+ * How loud a sound must be to trigger: `1` lowest, `3` mid, `5` highest, on the app's own
6114
+ * three-position control. A `scalar` rather than an enum — the endpoints and the midpoint are
6115
+ * observed, so 2 and 4 are a prediction of the scale's shape rather than values the wire is known to
6116
+ * take. Independent of {@link CAMERA_MEMBERS.soundDetection}: the camera stores it whether sound
6117
+ * detection is on or off.
6118
+ */
6119
+ soundDetectionSensitivity: {
6120
+ param: CAMERA_CMD.SOUND_DETECTION_SENSITIVITY,
6171
6121
  type: "number",
6172
- unit: "%",
6173
- kind: "percent",
6174
- writeOnly: true,
6122
+ kind: "scalar",
6175
6123
  provenance: "verified",
6176
- available: isHomeBase,
6177
- requires: HUB_ALARM_EVIDENCE,
6178
- min: 0,
6179
- max: 100,
6180
- description: "HomeBase alarm volume 0..100 (1235 CMD_SET_HUB_SPK_VOLUME). Verified audible on a T8030.",
6181
- write: (v) => setStationScalar(SIREN_CMD.HUB_SPK_VOLUME, Number(v), STATION_CHANNEL)
6124
+ available: isCameraCodec,
6125
+ description: "Sound-detection sensitivity: 1 = lowest, 3 = mid, 5 = highest (SOUND_DETECTION_SENSITIVITY 6044). \u2705 Write confirmed live on a T8410: 1 and 5 sent consecutively through this member and read back on the cloud param. 2 and 4 are the scale's shape, not observed values.",
6126
+ write: (v, ctx) => {
6127
+ const n = Number(v);
6128
+ if (!Number.isInteger(n) || n < 1 || n > 5)
6129
+ return void 0;
6130
+ return setJson(CAMERA_CMD.SOUND_DETECTION_SENSITIVITY, { index: n }, ctx);
6131
+ }
6182
6132
  },
6183
- /** HomeBase alarm tone keeps its independently verified two-option domain. */
6184
- alarmTone: {
6185
- param: SIREN_CMD.HUB_ALARM_TONE,
6186
- property: "hubAlarmTone",
6187
- type: "number",
6133
+ /**
6134
+ * WHICH sounds count — any sound, or crying alone. `coerceEnumValue` refuses anything outside
6135
+ * {@link SoundDetectionType} rather than coercing it: both neighbouring values are real types, so a
6136
+ * coerced one arms the wrong trigger and reports success.
6137
+ */
6138
+ soundDetectionType: {
6139
+ param: CAMERA_CMD.SOUND_DETECTION_TYPE,
6140
+ type: "enum",
6188
6141
  kind: "enum",
6189
- enumValues: enumLabels(HubAlarmTone),
6142
+ enumValues: { 1: "Crying", 2: "All Sound" },
6190
6143
  provenance: "verified",
6191
- available: isHomeBase,
6192
- requires: HUB_ALARM_EVIDENCE,
6193
- args: [{ name: "tone", kind: "enum", description: "One of the app's alarm tones (1-indexed)." }],
6194
- description: "HomeBase alarm tone selection (1281 APP_CMD_HUB_ALARM_TONE; 1350 SET_PAYLOAD {type}, mValue3 0). Wire verified live on a T8030.",
6144
+ available: isCameraCodec,
6145
+ description: "Which sounds trigger a detection: crying alone, or any sound (SOUND_DETECTION_TYPE 6046). \u2705 Write confirmed live on a T8410: both values sent consecutively through this member and read back on the cloud param.",
6195
6146
  write: (v, ctx) => {
6196
- const tone = coerceEnumValue(HubAlarmTone, v);
6197
- return tone === void 0 ? void 0 : setPayload(SIREN_CMD.HUB_ALARM_TONE, { type: tone }, ctx, 0);
6147
+ const type = coerceEnumValue(SoundDetectionType, v);
6148
+ return type == null ? void 0 : setJson(CAMERA_CMD.SOUND_DETECTION_TYPE, { type }, ctx);
6198
6149
  }
6199
6150
  },
6200
- /** Sound a standalone siren briefly using its dedicated installation-test wire. */
6201
- test: {
6202
- action: (ctx) => sirenPayload(SIREN_CMD.ALARM_TEST, {}, ctx),
6203
- available: isEvidencedStandaloneSiren,
6204
- description: "Sound the siren briefly as a test."
6205
- },
6206
- /** Trigger a verified station-family HomeBase or evidenced attached-camera alarm for a bounded duration. */
6207
- trigger: {
6208
- ...method(({ ctx, sink }) => async (seconds) => {
6209
- await sink.dispatch(triggerAlarm(validateTriggerDuration(seconds), ctx));
6210
- }, "Trigger the alarm for a positive whole-number duration in seconds.", (ctx) => isEvidencedStationAlarm(ctx) || isEvidencedAttachedCamera(ctx)),
6211
- args: [{ name: "seconds", kind: "seconds", min: 1, description: "A positive whole-number duration." }]
6212
- },
6213
- /** Stop a verified alarm-output family through its own wire contract. */
6214
- stop: method(({ ctx, sink }) => () => sink.dispatch(stopAlarm(ctx)), "Manually stop a sounding alarm.", (ctx) => alarmOutputFamily(ctx) !== void 0)
6215
- };
6216
- var SIREN = {
6217
- capability: "siren",
6218
- description: "Audible alarm output with evidence-bounded state, configuration, test, trigger, and stop members.",
6219
- members: SIREN_MEMBERS,
6220
- properties: propertiesOf(SIREN_MEMBERS),
6221
- /** Detection uses reported evidence and topology; no model string or device type admits a wire. */
6222
- detection: {
6223
- detect: (record, codec) => {
6224
- const homeBase = record.deviceType !== void 0 && HOMEBASE_TYPES.has(record.deviceType);
6225
- const homeBaseAlarm2 = homeBase && HUB_ALARM_EVIDENCE.some((param) => Object.hasOwn(record.params ?? {}, param));
6226
- const attachedCameraAlarm = codec === "camera" && !!record.parentSn && hasReportedEasSwitch(record);
6227
- const standaloneSiren = codec === "sensor" && hasStandaloneSirenEvidence(record);
6228
- return homeBaseAlarm2 || attachedCameraAlarm || standaloneSiren;
6229
- }
6230
- }
6231
- };
6232
-
6233
- // dist/model/capabilities/light.js
6234
- var LIGHT_CMD = {
6235
- /** Spotlight momentary on/off; `{time,type,value}` under 1700. ✅ Verified via P2P state readback (3/3). */
6236
- FLOODLIGHT_SWITCH: 1400,
6237
6151
  /**
6238
- * Spotlight brightness 1..100. ✅ SOLVED & verified live (T8425 + T8124): direct-binary command,
6239
- * 136-byte struct `[u32 ch][u32 value][account_id pad→128]` (value = raw 1..100), NOT a JSON wrapper.
6152
+ * `coerceEnumValue` refuses anything outside {@link NotificationStyle} instead of coercing it: every
6153
+ * neighbouring value is itself a real style, so a coerced one selects the wrong notification and
6154
+ * reports success.
6155
+ *
6156
+ * `transaction` is part of the verified frame — a decimal epoch-in-milliseconds string, fresh per
6157
+ * command.
6240
6158
  */
6241
- SPOTLIGHT_BRIGHTNESS: 1401,
6242
- /** Spotlight master enable — distinct from the on/off switch. ✅ Verified live T8124 (same 136-byte direct-binary struct as brightness). */
6243
- SPOTLIGHT_ENABLE: 1403,
6244
- /** Spotlight color temperature 0=warm..100=cool. ✅ Verified live T8124 (only tunable-white spotlights respond; T8425 is white-only). */
6245
- SPOTLIGHT_COLOR_TEMP: 1410,
6159
+ notificationStyle: {
6160
+ param: CAMERA_CMD.PUSH_NOTIFY_TYPE,
6161
+ type: "enum",
6162
+ kind: "enum",
6163
+ enumValues: { 1: "Text Only", 2: "Included Thumbnail", 3: "Text First, Then Thumbnail" },
6164
+ provenance: "verified",
6165
+ available: isCameraCodec,
6166
+ description: "How much a detection push carries: text only, a thumbnail, or text then a thumbnail (CMD_INDOOR_PUSH_NOTIFY_TYPE 6020). All three values wire-verified live on a standalone T8171 over the cloud/WAN path \u2014 the 1700 control-payload wrapper, each one read back on the cloud param. A value outside the enum is REJECTED, not clamped: every neighbouring value is a real style, so a bad one would silently select the wrong notification.",
6167
+ write: (v, ctx) => {
6168
+ const style = coerceEnumValue(NotificationStyle2, v);
6169
+ return style == null ? void 0 : setJson(CAMERA_CMD.PUSH_NOTIFY_TYPE, { value: style, transaction: String(Date.now()) }, ctx);
6170
+ }
6171
+ },
6246
6172
  /**
6247
- * Auto-spotlight = "motion-activated light" — light up when motion fires. ✅ Wire reversed from a live
6248
- * OUTBOUND P2P capture + replay-verified on T8425 (ch3, 2026-07-13, ON+OFF): the real command
6249
- * is **cmd 1422**, carried in a `1350` SET_PAYLOAD envelope on the DEVICE channel (mChannel = device
6250
- * channel, mValue3 0), with a COMPOSITE payload — NOT a bare bool:
6251
- * `{brightness:0..100, enable:0|1, latitude:"", longtitude:"" (sic), mode:1, schedule:[], sunset2rise:0, time}`
6252
- * `enable` is the on/off; `time` = auto-off seconds; `mode`/`schedule`/`sunset2rise` are the scheduling
6253
- * knobs. See {@link AutoSpotlightOptions}.
6254
- * NB: the app-JS constant `APP_CMD_SET_LIGHT_CTRL_PIR_SWITCH` (1408) is a RED HERRING for this device —
6255
- * the T8425 never emits 1408; three 1408 wire attempts all no-op'd. (No `1408` constant is defined — it's
6256
- * dead; do not add one.)
6173
+ * Three modes, and `FullColor` is the conditional one: a model without a spotlight or starlight
6174
+ * sensor omits it, and the wire accepts the value regardless — the device's own reported set is the
6175
+ * only statement of which of the three it has. `coerceEnumValue` rejects a value outside
6176
+ * {@link NightVision} rather than coercing it. The payload key on the wire is `night_sion`.
6257
6177
  */
6258
- MOTION_ACTIVATE_LIGHT: 1422
6259
- };
6260
- var BRIGHTNESS_MIN = 1;
6261
- var BRIGHTNESS_MAX = 100;
6262
- function autoSpotlightCommand(on, ctx, opts = {}) {
6263
- return setPayload(LIGHT_CMD.MOTION_ACTIVATE_LIGHT, {
6264
- brightness: clamp(Number(opts.brightness ?? 50), BRIGHTNESS_MIN, BRIGHTNESS_MAX),
6265
- enable: on ? 1 : 0,
6266
- latitude: "",
6267
- longtitude: "",
6268
- mode: opts.mode ?? 1,
6269
- schedule: [],
6270
- sunset2rise: 0,
6271
- time: opts.time ?? 30
6272
- }, ctx, 0, void 0, "auto");
6273
- }
6274
- function lightSwitchWire(ctx) {
6275
- const t = ctx.deviceType;
6276
- if (t === void 0)
6277
- return void 0;
6278
- if (LIGHT_SWITCH_JSON_TYPES.has(t))
6279
- return "json";
6280
- if (LIGHT_SWITCH_INT_STRING_TYPES.has(t))
6281
- return "int-string";
6282
- return void 0;
6283
- }
6284
- var LIGHT_SWITCH_JSON_TYPES = /* @__PURE__ */ new Set([
6285
- DeviceType.FLOODLIGHT_CAMERA_8425,
6286
- ...WALL_LIGHT_TYPES,
6287
- DeviceType.INDOOR_PT_CAMERA,
6288
- DeviceType.INDOOR_PT_CAMERA_1080,
6289
- DeviceType.CAMERA_4G_S330
6290
- ]);
6291
- var LIGHT_SWITCH_INT_STRING_TYPES = /* @__PURE__ */ new Set([
6292
- DeviceType.FLOODLIGHT_CAMERA_8423,
6293
- DeviceType.INDOOR_OUTDOOR_CAMERA_1080P_NO_LIGHT,
6294
- DeviceType.INDOOR_OUTDOOR_CAMERA_2K,
6295
- DeviceType.INDOOR_OUTDOOR_CAMERA_1080P,
6296
- DeviceType.OUTDOOR_PT_CAMERA,
6297
- DeviceType.CAMERA2C,
6298
- DeviceType.CAMERA2,
6299
- DeviceType.CAMERA2_PRO,
6300
- DeviceType.CAMERA2C_PRO,
6301
- DeviceType.CAMERA3,
6302
- DeviceType.CAMERA3C,
6303
- DeviceType.PROFESSIONAL_247
6304
- ]);
6305
- function switchCommand(on, ctx) {
6306
- switch (lightSwitchWire(ctx)) {
6307
- case "json":
6308
- return setJson(LIGHT_CMD.FLOODLIGHT_SWITCH, { time: 0, type: 2, value: on ? 1 : 0 }, ctx);
6309
- case "int-string":
6310
- return setScalar(LIGHT_CMD.FLOODLIGHT_SWITCH, on ? 1 : 0, ctx, "int-string");
6311
- default:
6312
- return void 0;
6313
- }
6314
- }
6315
- function directBinary(param, value, ctx) {
6316
- return setScalar(param, value, ctx, "direct-binary");
6317
- }
6318
- function switchFrame(on, ctx) {
6319
- const cmd = switchCommand(on, ctx);
6320
- if (!cmd)
6321
- throw new Error(`light: unknown spotlight switch wire format \u2014 confirm it [${describeDevice(ctx)}]`);
6322
- return cmd;
6323
- }
6324
- var LIGHT_MEMBERS = {
6178
+ nightVision: {
6179
+ param: CAMERA_CMD.NIGHT_VISION_TYPE,
6180
+ type: "enum",
6181
+ kind: "enum",
6182
+ enumValues: { 0: "Off", 1: "Infrared", 2: "Full Color" },
6183
+ enumValuesFor: (ctx) => isT8442(ctx) ? { 0: "Off", 1: "Infrared" } : void 0,
6184
+ provenance: "verified",
6185
+ available: isCameraCodec,
6186
+ readAvailable: (ctx) => !isT8442(ctx),
6187
+ readAliases: [{ paramType: 2002, available: isT8442 }],
6188
+ description: "Night-vision mode: 0 = off, 1 = infrared (B&W), 2 = full colour where supported. T8425 uses NIGHT_VISION_TYPE 1277 (1350 SET_PAYLOAD, night_sion); T8442 reports 2002 and writes direct IC_NIGHT_VISION_TYPE 1013 (int-string, 0/1). Both wires verified live.",
6189
+ write: (v, ctx) => {
6190
+ const nv = coerceEnumValue(NightVision, v);
6191
+ if (nv == null)
6192
+ return void 0;
6193
+ if (isT8442(ctx)) {
6194
+ if (nv === NightVision.FullColor)
6195
+ throw new Error("nightVision: Full Color is not supported on T8442 (only Off/Infrared)");
6196
+ return setScalar(CAMERA_CMD.IC_NIGHT_VISION_TYPE, nv, ctx, "int-string");
6197
+ }
6198
+ return setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0, "auto");
6199
+ }
6200
+ },
6325
6201
  /**
6326
- * Whether the lamp is lit RIGHT NOW — the momentary switch, not a setting. Measured on a T8170: 1400
6327
- * goes to `1` when a client lights the spotlight and back to `0` when that client stops, so the value
6328
- * tracks whoever is streaming rather than a preference anyone set. The vendor app lights the lamp for a
6329
- * live view and drops it on quitting.
6202
+ * Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
6203
+ * different wire: changing one leaves the other's parameter untouched.
6330
6204
  *
6331
- * So it reads on whenever any client is watching. The setting a user changes and expects to persist
6332
- * is {@link spotlightEnabled}.
6205
+ * The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
6206
+ * `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
6207
+ * carries the tier in force; the tier is what this property means. 1020 is also the name the param
6208
+ * dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
6209
+ * this name would collide with it on a device reporting both.
6333
6210
  *
6334
- * The switch refuses for a reason a value check cannot give — this model's frame SHAPE is unconfirmed —
6335
- * so `switchFrame` throws that reason and `bindMembers` turns it into the rejection, rather than
6336
- * letting the generated "not a valid value" blame a boolean that was never the problem.
6211
+ * `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
6212
+ * confirmed frame carries no such field; neither is inferred from the other.
6213
+ *
6214
+ * Not offered where the device reports the doorbell's own live-view quality (1705).
6337
6215
  */
6338
- isOn: {
6339
- param: LIGHT_CMD.FLOODLIGHT_SWITCH,
6340
- property: "light",
6341
- type: "bool",
6342
- kind: "boolean",
6216
+ streamingQuality: {
6217
+ param: 1020,
6218
+ type: "number",
6219
+ kind: "enum",
6220
+ enumValues: STREAMING_QUALITY_TIERS,
6343
6221
  provenance: "verified",
6344
- description: "Light on/off (1400) \u2014 lit right now, not a setting: it follows whichever client is streaming. Verified via P2P state readback (3/3); session-scoped behaviour measured live on a T8170.",
6345
- write: (v, ctx) => switchFrame(asBool(v), ctx),
6346
- writeAs: "set",
6347
- aliases: { on: true, off: false }
6222
+ available: (ctx) => isCameraCodec(ctx) && !(ctx.paramIds?.has(DOORBELL_CMD.VIDEO_QUALITY) ?? false),
6223
+ args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
6224
+ ...accepts(),
6225
+ description: "Live-view quality as a tier, 0 = Auto (1020; the write rides 2730 APP_CMD_MULTI_CAM_SET_VIDEO_QUALITY). Distinct from recordingQuality, which is what gets stored. All four tiers wire-verified live on a T8170 and read back on 1020. Sent from this SDK to a standalone T8170 and T8171, tier 1 holds the live stream at 1280x720 and tier 0 restores the camera's own resolution ladder \u2014 see CAMERA_CMD.STREAMING_QUALITY_SET. A value outside the tier set is REJECTED, not clamped: every tier in range is a real quality, so a bad one would silently pick another.",
6226
+ write: (v, ctx) => {
6227
+ const q = resolveStreamingQualityTier(v);
6228
+ return q == null ? void 0 : setPayload(CAMERA_CMD.STREAMING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q, transaction: String(Date.now()) }, ctx, 0, void 0, "auto");
6229
+ }
6348
6230
  },
6349
6231
  /**
6350
- * Manual level for the spotlight, 1-100 — the floor is 1 rather than 0 because a 0 reads as "off" and
6351
- * that is `isOn`'s job. The write is pinned to the level-2 direct-binary frame (`directBinary`),
6352
- * the only shape captured for it, so a standalone camera that never negotiates a level-2 key cannot
6353
- * set it even though the getter reads fine.
6232
+ * `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
6233
+ * of it by `decode`, so the getter answers a tier while the schema stays honest. The setter
6234
+ * takes a resolution NAME as well as the tier the getter answers.
6354
6235
  */
6355
- brightness: {
6356
- param: LIGHT_CMD.SPOTLIGHT_BRIGHTNESS,
6357
- type: "number",
6358
- unit: "%",
6359
- kind: "percent",
6236
+ recordingQuality: {
6237
+ param: CAMERA_CMD.RECORDING_QUALITY_SET,
6238
+ type: "string",
6360
6239
  provenance: "verified",
6361
- min: BRIGHTNESS_MIN,
6362
- max: BRIGHTNESS_MAX,
6363
- description: "Manual brightness, range 1-100 (1401). Verified live (T8425 + T8124).",
6364
- write: (v, ctx) => directBinary(LIGHT_CMD.SPOTLIGHT_BRIGHTNESS, Number(v), ctx)
6240
+ available: isCameraCodec,
6241
+ decode: (raw) => decodeRecordingQualityTier(raw),
6242
+ decodedKind: "enum",
6243
+ decodedValues: Object.keys(RECORDING_QUALITY_TIERS).map(Number),
6244
+ // Label the decoded tiers so a host can render this as a labelled choice (a dropdown) rather than
6245
+ // a bare number — same shape as `suction` and the sibling `streamingQuality`. `decode` still owns
6246
+ // the value (a tier); these are only the tier→label map, keyed to match `decodedValues`.
6247
+ enumValues: RECORDING_QUALITY_TIERS,
6248
+ description: "RECORDING quality as a tier (2731) \u2014 distinct from streamingQuality, which is the live view. The device reports the whole config \u2014 `{cur_mode, mode_<n>:{quality}}`, read live off a T8170 \u2014 and the ACTIVE mode's tier is lifted out of it on read. \u2705 write wire verified live (T8425): 1350 SET_PAYLOAD {channel:0, mode:0, primary_view:0, quality:N}. Tier\u2192label in RECORDING_QUALITY_TIERS / resolveRecordingQuality (verified on T8425; add a per-model map if a model's tiers ever diverge).",
6249
+ args: [{ name: "quality", kind: "enum", description: "A tier; the resolution name it maps to is accepted too." }],
6250
+ ...accepts(),
6251
+ write: (v, ctx) => {
6252
+ const q = resolveRecordingQualityTier(v);
6253
+ return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0, void 0, "auto");
6254
+ }
6365
6255
  },
6366
6256
  /**
6367
- * Warm-to-cool on a 0-100 scale — a `scalar`, not a percentage or a mired value, since 0 is one end
6368
- * of a range rather than "none". `writeOnly`, so there is a setter and no getter at all: the device
6369
- * accepts the setting and never reports it back.
6370
- * Only tunable-white spotlights respond; a white-only model accepts the frame and does nothing.
6257
+ * Not every model has the feature, and one without it accepts the frame without acting on it — so the
6258
+ * write is offered only where the device reports 1015, the same param the read is gated on.
6371
6259
  */
6372
- colorTemp: {
6373
- param: LIGHT_CMD.SPOTLIGHT_COLOR_TEMP,
6374
- type: "number",
6375
- kind: "scalar",
6376
- provenance: "verified",
6377
- writeOnly: true,
6378
- min: 0,
6379
- max: 100,
6380
- description: "Colour temperature, 0 (warm) to 100 (cool). Not reported back, so it is write-only.",
6381
- write: (v, ctx) => directBinary(LIGHT_CMD.SPOTLIGHT_COLOR_TEMP, Number(v), ctx)
6260
+ antiTheftDetection: {
6261
+ param: CAMERA_CMD.EAS_SWITCH,
6262
+ type: "bool",
6263
+ kind: "boolean",
6264
+ provenance: "apk",
6265
+ available: isCameraCodec,
6266
+ description: "Anti-theft detection on/off (1015 APP_CMD_EAS_SWITCH; the app parses it as anti_theft_detection_switch). Adaptive scalar {value:0|1}. \u26A0\uFE0F Replay+readback confirmed on a HomeBase-attached T8425 only; standalone unverified and v3 devices use id 2735 \u2014 see CAMERA_CMD.EAS_SWITCH.",
6267
+ requires: [CAMERA_CMD.EAS_SWITCH],
6268
+ write: (v, ctx) => setScalar(CAMERA_CMD.EAS_SWITCH, asBool(v) ? 1 : 0, ctx, "auto")
6382
6269
  },
6383
6270
  /**
6384
- * The master enable, one level above {@link isOn}: this decides whether the spotlight may light at all,
6385
- * where `isOn` is the momentary switch. `writeAs` names the setter `setEnabled` rather than the
6386
- * `setSpotlightEnabled` the key would derive, since the capability is already the spotlight.
6387
- *
6388
- * Reported, so it is a read as well as a write. ✅ Verified live on a T8170: the cloud device list
6389
- * carries 1403, and its value tracks the vendor app's spotlight setting in both directions — `1` to `0`
6390
- * when the setting is switched off, `0` to `1` when it is switched back on, so the polarity is direct
6391
- * and 1 means enabled. The poll reports a param only when its PREVIOUS value differed, so the id is in
6392
- * the record rather than newly appearing. The param dictionary names it `floodlightTotalSwitch`
6393
- * (`app:FLOODLIGHT_TOTAL_SWITCH`) and lists the T8170 among its models.
6394
- *
6395
- * This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
6396
- * the lamp being lit right now, driven by whichever
6397
- * client is streaming — the vendor app lights it for a live view and drops it on quitting.
6271
+ * Privacy mode — the multi-frame burst. Nothing reports it back, so it is a setter with no getter, and
6272
+ * it declares no param: the id the burst is built from is the transport's, not this capability's.
6398
6273
  *
6399
- * The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
6400
- * unit.
6274
+ * Being write-only, it is named by `unobservableMembers(dev.camera())`, which distinguishes "this camera
6275
+ * is not in privacy mode" from "this camera cannot say" rather than leaving both as `undefined`. That
6276
+ * distinction matters most on the families whose power rides this same envelope — see {@link enabled}.
6401
6277
  */
6402
- spotlightEnabled: {
6403
- param: LIGHT_CMD.SPOTLIGHT_ENABLE,
6278
+ privacy: {
6404
6279
  type: "bool",
6405
6280
  kind: "boolean",
6281
+ writeOnly: true,
6406
6282
  provenance: "verified",
6407
- description: "The spotlight master switch, distinct from the on/off above \u2014 whether the spotlight may light at all. Read verified live on a T8170 (param 1403, direct polarity: 1 = enabled).",
6408
- write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
6409
- writeAs: "setEnabled"
6410
- }
6411
- };
6412
- var LIGHT = {
6413
- capability: "light",
6414
- description: "Floodlight / spotlight manual switch and brightness.",
6415
- members: LIGHT_MEMBERS,
6416
- properties: propertiesOf(LIGHT_MEMBERS),
6417
- // Three T8171 units on firmware 1.2.1.6 report 1400/1401/1403 without a spotlight, so those
6418
- // param ids are not sufficient evidence for that model. The camera status LED (1045) is not light.
6419
- // Other models keep the existing param and name evidence.
6420
- detection: {
6421
- modelHints: [/floodlight|wall.?light|spot.?light|search.?light/i],
6422
- detect: (record) => record.model !== "T8171" && [1400, 1401, 1403, 6080, 6082, 1413].some((param) => Object.hasOwn(record.params ?? {}, param))
6283
+ available: isCameraCodec,
6284
+ description: "Privacy mode (PRIVACY_MODE 6250) \u2014 the multi-frame burst the sink plays.",
6285
+ write: (v, ctx) => privacyCommand(asBool(v), ctx.channel)
6423
6286
  },
6424
6287
  /**
6425
- * Only what the member table cannot express: `on`/`off` as no-argument verbs over the same wire as the
6426
- * derived `set`, and the write-only auto-spotlight composite. The verbs stay UNDESCRIBED on purpose —
6427
- * `set` is the method that takes the value, and describing all three would offer one state as three
6428
- * controls. `async` is what turns `switchFrame`'s throw into the rejection a `Promise<void>`
6429
- * caller expects, the same shape the derived setter gets from `bindMembers`.
6288
+ * The same status LED is reported under 1045 on ordinary cameras and 1716 on video doorbells. The
6289
+ * alias is the same semantic value on a family-specific wire. The parameter valid for the resolved
6290
+ * family is sufficient evidence to install both the getter and family-aware setter.
6430
6291
  */
6431
- actions({ ctx, sink }) {
6432
- const dispatchSwitch = async (on) => sink.dispatch(switchFrame(on, ctx));
6433
- return {
6434
- on: () => dispatchSwitch(true),
6435
- off: () => dispatchSwitch(false),
6436
- setAutoSpotlight: (on, opts) => sink.dispatch(autoSpotlightCommand(asBool(on), ctx, opts))
6437
- };
6438
- }
6439
- };
6440
-
6441
- // dist/model/capabilities/smart-light.js
6442
- var LIGHT_MQTT_CMD = 17;
6443
- var LIGHT_MQTT_REPORT_CMD = 16;
6444
- var LIGHT_CMD2 = {
6445
- /** `getDeviceInfoPayloadData` — request a full status report. */
6446
- GET_DEVICE_INFO: 512,
6447
- /** `setDeviceInfoPayloadData` — on/off (tag 0xa3) + brightness (tag 0xa4). */
6448
- SET_DEVICE_INFO: 513,
6449
- /** The device→app status report (`event_device_status_notify`), sent unsolicited. */
6450
- DEVICE_STATUS_REPORT: 516,
6451
- /** `setLightEffectParamsPayloadData` — apply one plain custom colour to evidenced segments. */
6452
- SET_LIGHT_EFFECT_PARAMS: 518,
6453
- /** The device's answer to {@link LIGHT_CMD.GET_DEVICE_INFO}. */
6454
- GET_DEVICE_INFO_REPLY: 2560,
6455
- /** `setLightEffectParamsV2PayloadData` — select a gallery effect by catalog id. WIRED (see doc). */
6456
- SET_LIGHT_EFFECT_PARAMS_V2: 525
6457
- };
6458
- var GET_DEVICE_INFO_ALL = 8191;
6459
- var REQUEST_STATUS_TAG = 163;
6460
- var WRITE_TAG = { IS_ON: 163, LEVEL: 164 };
6461
- var LIGHT_PARAM = {
6462
- POWER: 161,
6463
- BRIGHTNESS: 162,
6464
- LENGTH: 163,
6465
- EFFECT_ID: 164,
6466
- COLOR_GRADIENT: 165,
6467
- CLOUD_EFFECT_ID: 166,
6468
- EFFECT_MODE: 167
6469
- };
6470
- var COMMON_TAGS = /* @__PURE__ */ new Map([
6471
- [161, LIGHT_PARAM.POWER],
6472
- [162, LIGHT_PARAM.BRIGHTNESS],
6473
- [163, LIGHT_PARAM.LENGTH],
6474
- [164, LIGHT_PARAM.EFFECT_ID],
6475
- [165, LIGHT_PARAM.COLOR_GRADIENT],
6476
- [166, LIGHT_PARAM.CLOUD_EFFECT_ID]
6477
- ]);
6478
- var MAX_NUMERIC_LEN = 4;
6479
- var FRAME_LAYOUTS = /* @__PURE__ */ new Map([
6480
- [LIGHT_CMD2.DEVICE_STATUS_REPORT, { modeTag: 167 }],
6481
- [LIGHT_CMD2.GET_DEVICE_INFO_REPLY, { modeTag: 168 }]
6482
- ]);
6483
- var BOOL_PARAMS = /* @__PURE__ */ new Set([LIGHT_PARAM.POWER, LIGHT_PARAM.COLOR_GRADIENT]);
6484
- function reportFrom(signal) {
6485
- if (signal.source !== "mqtt" || !signal.frame)
6486
- return void 0;
6487
- const { envelopeCmd, cmd, fields } = signal.frame;
6488
- if (envelopeCmd !== LIGHT_MQTT_REPORT_CMD)
6489
- return void 0;
6490
- const layout = FRAME_LAYOUTS.get(cmd);
6491
- if (!layout)
6492
- return void 0;
6493
- const params = {};
6494
- for (const { tag: tag2, value } of fields) {
6495
- const param = tag2 === layout.modeTag ? LIGHT_PARAM.EFFECT_MODE : COMMON_TAGS.get(tag2);
6496
- if (param === void 0 || value.length === 0 || value.length > MAX_NUMERIC_LEN)
6497
- continue;
6498
- const n = value.readUIntLE(0, value.length);
6499
- params[param] = BOOL_PARAMS.has(param) ? n === 1 ? "1" : "0" : String(n);
6500
- }
6501
- return Object.keys(params).length ? params : void 0;
6502
- }
6503
- var BRIGHTNESS_MIN2 = 0;
6504
- var BRIGHTNESS_MAX2 = 100;
6505
- function deviceInfoCommand(opts) {
6506
- const fields = [];
6507
- if (opts.isOn !== void 0)
6508
- fields.push({ tag: WRITE_TAG.IS_ON, value: Buffer.from([opts.isOn ? 1 : 0]) });
6509
- if (opts.level !== void 0) {
6510
- fields.push({
6511
- tag: WRITE_TAG.LEVEL,
6512
- value: Buffer.from([clamp(Math.round(opts.level), BRIGHTNESS_MIN2, BRIGHTNESS_MAX2)])
6513
- });
6514
- }
6515
- return { kind: "mqtt-dp", mqttCmdCode: LIGHT_MQTT_CMD, cmdCode: LIGHT_CMD2.SET_DEVICE_INFO, fields };
6516
- }
6517
- function requestStateCommand() {
6518
- const value = Buffer.alloc(4);
6519
- value.writeUInt32LE(GET_DEVICE_INFO_ALL, 0);
6520
- return {
6521
- kind: "mqtt-dp",
6522
- mqttCmdCode: LIGHT_MQTT_CMD,
6523
- cmdCode: LIGHT_CMD2.GET_DEVICE_INFO,
6524
- fields: [{ tag: REQUEST_STATUS_TAG, value }]
6525
- };
6526
- }
6527
- function presetCommand(lightId) {
6528
- return {
6529
- kind: "mqtt-dp-preset",
6530
- mqttCmdCode: LIGHT_MQTT_CMD,
6531
- cmdCode: LIGHT_CMD2.SET_LIGHT_EFFECT_PARAMS_V2,
6532
- companionCmdCode: LIGHT_CMD2.SET_DEVICE_INFO,
6533
- presetId: lightId
6534
- };
6535
- }
6536
- var CONFIRMED_EFFECT_MODELS = /* @__PURE__ */ new Set(["T8L02"]);
6537
- var CONFIRMED_COLOR_MODELS = /* @__PURE__ */ new Set(["T8L02"]);
6538
- var MAX_COLOR_SEGMENTS = 254;
6539
- var modelKey = (model) => (model ?? "").trim().toUpperCase();
6540
- function colorCommand(color, segmentCount) {
6541
- return {
6542
- kind: "mqtt-dp-color",
6543
- mqttCmdCode: LIGHT_MQTT_CMD,
6544
- cmdCode: LIGHT_CMD2.SET_LIGHT_EFFECT_PARAMS,
6545
- ...color,
6546
- segmentCount
6547
- };
6548
- }
6549
- var SMART_LIGHT_MEMBERS = {
6550
- /**
6551
- * On/off for the whole run of lights. `writeAs` names the setter `set` rather than `setPower`, and the
6552
- * `on`/`off` verbs beside it in `actions()` reach the same frame. `realtime` like every read here:
6553
- * there is no pollable cloud param, so the getter answers `undefined` until the first MQTT report —
6554
- * `refreshState` is how a caller populates it on demand. Note the READ tag (0xa1) and the WRITE tag
6555
- * (0xa3) differ; the two legs' tag maps are unrelated.
6556
- */
6557
- power: {
6558
- param: LIGHT_PARAM.POWER,
6559
- property: "lightPower",
6560
- type: "bool",
6561
- kind: "boolean",
6562
- provenance: "verified",
6563
- realtime: true,
6564
- description: "Power state, DP tag 0xa1. \u2705 Source-confirmed in the app's own report parser, then live-decoded off a T8L02 status report.",
6565
- write: (v) => deviceInfoCommand({ isOn: asBool(v) }),
6566
- writeAs: "set",
6567
- aliases: { on: true, off: false }
6568
- },
6569
- /**
6570
- * The CONFIGURED level, not the live output: it persists across an off, so reading a non-zero
6571
- * brightness says nothing about whether the lights are lit — pair it with `power`. 0 is a legal level
6572
- * here (unlike the camera spotlight's 1-100), and the write clamps into 0-100 rather than refusing.
6573
- * `realtime`, so `undefined` until the first report.
6574
- */
6575
- brightness: {
6576
- param: LIGHT_PARAM.BRIGHTNESS,
6577
- property: "lightBrightness",
6578
- type: "number",
6579
- unit: "%",
6580
- kind: "percent",
6581
- provenance: "verified",
6582
- realtime: true,
6583
- min: BRIGHTNESS_MIN2,
6584
- max: BRIGHTNESS_MAX2,
6585
- description: "Brightness 0-100, DP tag 0xa2. Persists across off \u2014 it is the configured level, not the live output. \u2705 Source-confirmed in the app's own report parser, then echoed back the value written, live.",
6586
- write: (v) => deviceInfoCommand({ level: Number(v) })
6587
- },
6588
- /**
6589
- * How many individually addressable segments the installed run has — a physical fact of the strip,
6590
- * which is why it is read-only. A bare `scalar` with no unit: the wire says how many, not how long.
6591
- * `realtime`, so `undefined` until the first report.
6592
- */
6593
- lightLength: {
6594
- param: LIGHT_PARAM.LENGTH,
6595
- type: "number",
6596
- kind: "scalar",
6597
- provenance: "verified",
6598
- realtime: true,
6599
- description: "Addressable segment count, DP tag 0xa3."
6600
- },
6601
- /**
6602
- * The gallery effect SELECTED — an `identifier`, not an enum, because it names an entry in a catalog
6603
- * that only exists at runtime, so there is no option set to publish. Distinct from `cloudEffectId`,
6604
- * which is the one actually running. Written by `setEffect`, whose gate is the model rather than a
6605
- * value. `realtime`, so `undefined` until the first report.
6606
- */
6607
- effectId: {
6608
- param: LIGHT_PARAM.EFFECT_ID,
6609
- property: "lightEffectId",
6610
- type: "number",
6611
- kind: "identifier",
6612
- provenance: "verified",
6613
- realtime: true,
6614
- description: "Selected gallery effect id, DP tag 0xa4."
6615
- },
6616
- /**
6617
- * Whether the run blends between an effect's colours rather than stepping between them. Read-only:
6618
- * the report parser establishes the flag, but no write frame for it has been reversed, so no setter
6619
- * is offered. One of the two params surfaced as `"1"`/`"0"` so the `bool` narrowing reads it.
6620
- * `realtime`, so `undefined` until the first report.
6621
- */
6622
- colorGradient: {
6623
- param: LIGHT_PARAM.COLOR_GRADIENT,
6624
- property: "lightColorGradient",
6292
+ statusLed: {
6293
+ param: CAMERA_CMD.DEV_LED_SWITCH,
6625
6294
  type: "bool",
6626
6295
  kind: "boolean",
6627
6296
  provenance: "verified",
6628
- realtime: true,
6629
- description: "Colour-gradient switch, DP tag 0xa5."
6630
- },
6631
- /**
6632
- * The effect actually RUNNING, `0` when none is — so this, not `effectId`, is the read that answers
6633
- * "is an effect playing". An `identifier` for the same reason as its sibling: the catalog it indexes
6634
- * is fetched at runtime. Read-only; `setEffect` drives the selection. `realtime`, so `undefined`
6635
- * until the first report.
6636
- */
6637
- cloudEffectId: {
6638
- param: LIGHT_PARAM.CLOUD_EFFECT_ID,
6639
- property: "lightCloudEffectId",
6640
- type: "number",
6641
- kind: "identifier",
6642
- provenance: "verified",
6643
- realtime: true,
6644
- description: "Running gallery effect id (0 when off), DP tag 0xa6."
6645
- },
6646
- /**
6647
- * Reported in every status frame, so it belongs in the schema and answers through `getProperty` — but
6648
- * given no typed getter: the app's own parser reads 0xa7 differently on the notify and on the
6649
- * get-reply, and no capture settles which value means what.
6650
- */
6651
- lightEffectMode: {
6652
- param: LIGHT_PARAM.EFFECT_MODE,
6653
- type: "number",
6654
- kind: "scalar",
6655
- provenance: "apk",
6656
- unexposed: true,
6657
- description: "Effect mode, DP tag 0xa7. Reported, but its value space is not evidenced \u2014 no typed getter, and a bare mode index rather than a named set, since the options it selects among are unknown."
6658
- },
6659
- /**
6660
- * A request, not a state: reports are pushed on change with no periodic heartbeat, so this is how a
6661
- * caller populates the getters on demand. The reply arrives asynchronously and does not resolve here.
6662
- */
6663
- refreshState: {
6664
- action: () => requestStateCommand(),
6665
- description: "Ask the device to report its current state; the reply refreshes the reads."
6297
+ available: isCameraCodec,
6298
+ readAvailable: (ctx) => !hasCapability(ctx, "doorbell"),
6299
+ readAliases: [{ paramType: CAMERA_CMD.DOORBELL_LED, available: (ctx) => hasCapability(ctx, "doorbell") }],
6300
+ requiresRead: true,
6301
+ description: "Camera status LED. Video doorbells report the same state under their button-ring LED parameter.",
6302
+ write: (v, ctx) => statusLedCommand(asBool(v), ctx)
6666
6303
  },
6304
+ views: provided("media", (m) => m.views && (() => m.views), "Optical view identifiers supported by this camera's media route."),
6667
6305
  /**
6668
- * Gated on the MODEL, not on a value, and carrying no description: it refuses off the confirmed list
6669
- * for a reason a generated message cannot give, and its id names an entry in a catalog that only
6670
- * exists at runtime, so a generated control would have no domain to offer. Neither is a property
6671
- * write, so it keeps its own signature.
6306
+ * Media is not a property: it returns DATA rather than moving state, its options are richer than a
6307
+ * value, and it exists only on a device bound to a provider. Each is declared once, with its
6308
+ * signature taken FROM {@link MediaProvider} — so a change there is a compile error here, not a drift.
6309
+ *
6310
+ * Every pull is refused where a reliable {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
6311
+ * `live`, `snapshotLive`, `record`, `openReadable` and `recordFragments` alike, since each opens media on a
6312
+ * camera that serves none. That reading is the on/off source; a live probe is not one, since a disabled
6313
+ * camera answers a start with audio and never a video frame.
6314
+ *
6315
+ * {@link CAMERA_MEMBERS.snapshotStored} is exempt — a retained push thumbnail is not a pull. A reading of
6316
+ * `undefined` refuses nothing: families reporting neither wire param leave the state unknown, and unknown is
6317
+ * not known-off.
6318
+ *
6319
+ * The refusal REJECTS on the four that answer with a promise, and THROWS on
6320
+ * {@link CAMERA_MEMBERS.recordFragments}, which answers with a handle.
6672
6321
  */
6673
- setEffect: method(({ ctx, sink }) => (lightId) => CONFIRMED_EFFECT_MODELS.has(modelKey(ctx.model)) ? sink.dispatch(presetCommand(lightId)) : Promise.reject(new Error(`light-effect write is verified only on ${[...CONFIRMED_EFFECT_MODELS].join(", ")}; model ${ctx.model ?? "unknown"} uses a per-family effect-frame encoding that isn't reverse-engineered \u2014 refusing to send the captured SKU's bytes (on/off/brightness still work)`)), "Select a light-effect gallery entry by its catalog id. Colours are an RGBCW approximation, exact on primaries."),
6322
+ snapshotStored: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.snapshotStored && (() => m.snapshotStored()), "Latest validated push thumbnail retained in memory.", ["snapshot"]),
6323
+ snapshotLive: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (opts) => {
6324
+ refuseWhenDisabled(ctx, read);
6325
+ refuseUnsupportedView(m, opts);
6326
+ return m.snapshotLive({ powered: poweredOf(ctx), ...opts });
6327
+ }), "Fresh still decoded from a short live burst."),
6328
+ live: provided("media", (m, { ctx, read }) => async (opts) => {
6329
+ refuseWhenDisabled(ctx, read);
6330
+ refuseUnsupportedView(m, opts);
6331
+ return m.live({ powered: poweredOf(ctx), ...opts });
6332
+ }, "Open a managed live stream."),
6333
+ record: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (...args) => {
6334
+ refuseWhenDisabled(ctx, read);
6335
+ return m.record(...args);
6336
+ }), "Record N seconds \u2192 an mp4/h264 buffer."),
6337
+ openReadable: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && m.openReadable && (async (opts) => {
6338
+ refuseWhenDisabled(ctx, read);
6339
+ refuseUnsupportedView(m, opts);
6340
+ return m.openReadable({ powered: poweredOf(ctx), ...opts });
6341
+ }), "Open a node:stream Readable of the live feed."),
6342
+ recordFragments: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && m.recordFragments && ((opts) => {
6343
+ refuseWhenDisabled(ctx, read);
6344
+ return m.recordFragments({ powered: poweredOf(ctx), ...opts });
6345
+ }), "Continuous fragmented-MP4 (CMAF) recording."),
6346
+ downloadRecording: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
6674
6347
  /**
6675
- * Plain custom colour, distinct from the gallery-effect wire. The frame addresses every reported
6676
- * segment, so a current positive segment count is mandatory and no family-wide length is guessed.
6677
- * Completion acknowledges transport publication only; the device reports no authoritative RGB.
6348
+ * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
6349
+ * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
6350
+ * device never reported. Talkback holds a media session open for its duration, so it carries the same
6351
+ * power hint `live` does; without it a battery camera talked to with no stream running would stream
6352
+ * unbounded.
6678
6353
  */
6679
- setColor: method(({ ctx, sink, read }) => (color) => {
6680
- if (!CONFIRMED_COLOR_MODELS.has(modelKey(ctx.model))) {
6681
- return Promise.reject(new Error(`custom-colour write is verified only on ${[...CONFIRMED_COLOR_MODELS].join(", ")}; model ${ctx.model ?? "unknown"} has no confirmed 0x0206 frame encoding`));
6682
- }
6683
- const segmentCount = read("lightLength")?.value;
6684
- if (typeof segmentCount !== "number" || !Number.isInteger(segmentCount) || segmentCount < 1 || segmentCount > MAX_COLOR_SEGMENTS) {
6685
- return Promise.reject(new Error(`custom-colour write requires a reported integer segment count in 1..${MAX_COLOR_SEGMENTS}`));
6686
- }
6687
- const channels = color && [color.red, color.green, color.blue];
6688
- if (!channels || channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) {
6689
- return Promise.reject(new Error("custom-colour RGB channels must be integers in 0..255"));
6690
- }
6691
- return sink.dispatch(colorCommand(color, segmentCount));
6692
- }, "Set one plain RGB colour across all reported segments on verified T8L02 lights. Preserves configured brightness; completion acknowledges publication, not observed colour.", (ctx) => CONFIRMED_COLOR_MODELS.has(modelKey(ctx.model)))
6354
+ talkback: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.talkback && ctx.paramIds.has(AUDIO_CMD.AUDIO_SPEAKER) && ((opts) => m.talkback({ powered: poweredOf(ctx), ...opts })), "Push audio from the host to the camera's speaker.")
6693
6355
  };
6694
- var SMART_LIGHT = {
6695
- capability: "smart_light",
6696
- line: "life",
6697
- description: "eufy_life smart-lighting: T8L02 Permanent Outdoor Lights and similar (on/off/brightness/custom colour/effect).",
6698
- members: SMART_LIGHT_MEMBERS,
6699
- properties: propertiesOf(SMART_LIGHT_MEMBERS),
6700
- detection: { codecs: ["light"] },
6701
- /**
6702
- * Ask for a state snapshot as soon as this device's realtime channel is up, so the typed getters are
6703
- * populated at connect rather than only after the first write — reports are pushed on change, with
6704
- * no periodic heartbeat to wait for.
6705
- */
6706
- realtimeInit() {
6707
- return [requestStateCommand()];
6708
- },
6709
- decodeState(signal) {
6710
- const params = reportFrom(signal);
6711
- return params ? { params } : null;
6712
- },
6713
- emits: ["smartLightState"],
6714
- /**
6715
- * Surface a status report as one `smartLightState` event carrying every field it decoded. Fails
6716
- * closed (returns `null`, never throws) on any shape mismatch.
6717
- */
6718
- decodeEvent(signal) {
6719
- const params = reportFrom(signal);
6720
- if (!params)
6721
- return null;
6722
- const num2 = (id) => params[id] === void 0 ? void 0 : Number(params[id]);
6723
- const bool = (id) => params[id] === void 0 ? void 0 : params[id] === "1";
6724
- return {
6725
- event: "smartLightState",
6726
- payload: {
6727
- power: bool(LIGHT_PARAM.POWER),
6728
- brightness: num2(LIGHT_PARAM.BRIGHTNESS),
6729
- lightLength: num2(LIGHT_PARAM.LENGTH),
6730
- effectId: num2(LIGHT_PARAM.EFFECT_ID),
6731
- colorGradient: bool(LIGHT_PARAM.COLOR_GRADIENT),
6732
- cloudEffectId: num2(LIGHT_PARAM.CLOUD_EFFECT_ID)
6733
- }
6734
- };
6735
- },
6356
+ var CAMERA = {
6357
+ capability: "camera",
6358
+ description: "Camera power (on/off, CAMERA_SWITCH 1035) and privacy mode (PRIVACY_MODE 6250).",
6359
+ members: CAMERA_MEMBERS,
6360
+ properties: propertiesOf(CAMERA_MEMBERS),
6361
+ /** Every camera-codec device has the power/privacy surface. */
6362
+ detection: { codecs: ["camera"] },
6736
6363
  /** Only the no-argument power verbs, which carry no value for a member to hold. */
6737
- actions({ sink }) {
6364
+ actions({ ctx, sink }) {
6365
+ if (!isCameraCodec(ctx))
6366
+ return {};
6738
6367
  return {
6739
- on: () => sink.dispatch(deviceInfoCommand({ isOn: true })),
6740
- off: () => sink.dispatch(deviceInfoCommand({ isOn: false }))
6368
+ on: () => sink.dispatch(powerCommand(true, ctx)),
6369
+ off: () => sink.dispatch(powerCommand(false, ctx))
6741
6370
  };
6742
6371
  }
6743
6372
  };
6744
6373
 
6745
- // dist/model/capabilities/ptz.js
6746
- var PTZ_CMD = {
6747
- /** Rotate. App `SetPtz…`/`INDOOR_ROTATE`: 1700 wrapper, `{cmd_type,rotate_type,zoom}`. */
6748
- PTZ_ROTATE: 6030,
6749
- /** Digital zoom. App `SetPictureZoomParser` (`COMMAND_DUAL_CAMERA_ZOOM`): 1350 SET_PAYLOAD sub-command,
6750
- * payload `{x,y,w,h,offset,orgZoom,dstZoom}`. Also the inbound `1351` zoom-notify `cmd`. */
6751
- PTZ_ZOOM: 6203,
6752
- /** Go to a stored preset — AND the save-preset frame (identical bytes; the camera moves vs creates
6753
- * by whether the slot is occupied). App `SetPresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_POINT`):
6754
- * 1700 wrapper, `{settingstate:0, value:<presetId>}`. */
6755
- PTZ_PRESET_GOTO: 6032,
6756
- /** Capture the camera's current frame as a preset's thumbnail. App `COMMAND_APP_SPAN_PTZ_PIC`: 1700
6757
- * wrapper, `{value:<presetId>}`. The app sends this right before the 6032 save when creating a preset. */
6758
- PTZ_PRESET_PIC: 6097,
6759
- /** Delete a stored preset. App `DeletePresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_DELETE`):
6760
- * 1700 wrapper, `{value:<presetId>}`. */
6761
- PTZ_PRESET_DELETE: 6033,
6762
- /** List stored presets. App `QueryPresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_QUERY`): 1700
6763
- * request whose reply is a `1351` notify carrying `{points:[…]}`. */
6764
- PTZ_PRESET_QUERY: 6034,
6765
- /** Preview a stored preset — move to it transiently (the app uses this to show a preset before
6766
- * committing a default). App `COMMAND_INDOOR_SPAN_CRUISE_PREVIEW`: 1700 wrapper, `{value:<presetId>}`. */
6767
- PTZ_PRESET_PREVIEW: 6035,
6768
- /** Mark a stored preset as the default/home position. App `DefaultPresetPositionsParser`
6769
- * (`COMMAND_APP_SET_DEFAULT_POSITION`): 1350 SET_PAYLOAD, payload `{index:<presetId>, settingstate}`.
6770
- * A compound builder — the app follows it with PREVIEW (6035) + PTZ_PIC (6097) to refresh the
6771
- * thumbnail; the default-set itself is this frame. */
6772
- PTZ_SET_DEFAULT_POSITION: 6242,
6773
- /** How fast a rotate STEP travels. 1700 wrapper, `{value:1|3|5}`. Observed live: the app's
6774
- * Slow/Mid/Fast control on an indoor pan-tilt writes 1 / 3 / 5 and nothing else moves. The id sits
6775
- * inside the smart lock's `601x` block, so the shared param dictionary labels it `lockParam` — an id
6776
- * reused across product lines, and the camera's meaning is the observed one. */
6777
- PTZ_ROTATE_SPEED: 6015
6778
- };
6779
- var PTZ_FRAME = {
6780
- /** SoloCam JSON status wrapper — `{cmd, payload}`. */
6781
- NOTIFY_PAYLOAD: 1351,
6782
- /** Indoor-PT binary position stream wrapper. */
6783
- CONTROL_PAYLOAD: 1700
6374
+ // dist/model/capabilities/siren.js
6375
+ var SIREN_PARAM = {
6376
+ /** Whether the siren is sounding (app `APP_CMD_DEV_RING_STATUS`). 1 = sounding, 0 = silent. */
6377
+ RING_STATUS: 61008,
6378
+ /** Alarm volume as a device level (app `APP_CMD_SIREN_SENSOR_SET_ALARM_VOL`). See {@link SirenVolume}. */
6379
+ ALARM_VOLUME: 1825,
6380
+ /** Seconds an alarm sounds before stopping itself (app `APP_CMD_DEV_ALARM_TIMEOUT`). */
6381
+ ALARM_TIMEOUT: 61006,
6382
+ /** Do-not-disturb (app `APP_CMD_SENSOR_NOT_DISTURB`). */
6383
+ NOT_DISTURB: 1828
6784
6384
  };
6785
- var PtzDirection = {
6786
- /** Pan left (one step). */
6787
- left: "left",
6788
- /** Pan right (one step). */
6789
- right: "right",
6790
- /** Tilt up (one step). */
6791
- up: "up",
6792
- /** Tilt down (one step). */
6793
- down: "down"
6385
+ var SIREN_CMD = {
6386
+ /** Sound the siren briefly as a test (app `APP_CMD_SIREN_SENSOR_ALARM_TEST`). */
6387
+ ALARM_TEST: 1826,
6388
+ /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
6389
+ MANUAL_STOP: 1871,
6390
+ /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
6391
+ HOMEBASE_TONE: 1201,
6392
+ /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
6393
+ HUB_SPK_VOLUME: 1235,
6394
+ /** HomeBase alarm-tone selection (app `APP_CMD_HUB_ALARM_TONE`). */
6395
+ HUB_ALARM_TONE: 1281,
6396
+ /** Attached-camera duration alarm, verified live on T8114 and T8210 (app `SET_DEVS_TONE_FILE`). */
6397
+ CAMERA_TONE: 1202
6794
6398
  };
6795
- var PTZ_ROTATE = { left: 1, right: 2, up: 3, down: 4 };
6796
- var ZOOM_EVIDENCE_PARAMS = [6204, 6205];
6797
- var ZOOM_MODEL_HINT = /^T8170\b|^T8425\b|S340/i;
6798
- function hasZoomEvidence(ctx) {
6799
- return ZOOM_EVIDENCE_PARAMS.some((p) => ctx.paramIds.has(p)) || !!ctx.model && ZOOM_MODEL_HINT.test(ctx.model);
6800
- }
6801
- var PRESET_EVIDENCE_PARAMS = [1029, 6090, 6091, 6092, 6210];
6802
- var FIXED_LENS_MODELS = /* @__PURE__ */ new Set(["T8134"]);
6803
- function rotateCommand(direction, ctx, zoom = 1) {
6804
- return setJson(PTZ_CMD.PTZ_ROTATE, { cmd_type: 1, rotate_type: PTZ_ROTATE[direction], zoom }, ctx);
6399
+ var STATION_CHANNEL = 255;
6400
+ var SirenVolume = { Low: 1, Mid: 2, High: 3 };
6401
+ var SirenAlarmDuration = { Min1: 60, Min5: 300, Min10: 600, Min15: 900 };
6402
+ var HubAlarmTone = { Tone1: 1, Tone2: 2 };
6403
+ var HUB_ALARM_EVIDENCE = [SIREN_CMD.HUB_ALARM_TONE, 1282];
6404
+ function isStationAlarmOutput(ctx) {
6405
+ return ctx.deviceType === DeviceType.STATION || ctx.deviceType === DeviceType.HB3;
6805
6406
  }
6806
- function zoomCommand(dstZoom, ctx, region) {
6807
- const r = region ?? {};
6808
- const offset = r.offset ?? false;
6809
- return setPayload(PTZ_CMD.PTZ_ZOOM, {
6810
- x: offset ? r.x ?? 0 : 0,
6811
- y: offset ? r.y ?? 0 : 0,
6812
- w: offset ? r.w ?? 0 : 0,
6813
- h: offset ? r.h ?? 0 : 0,
6814
- offset,
6815
- orgZoom: r.orgZoom ?? 0,
6816
- dstZoom
6817
- }, ctx, 0, void 0, "auto");
6407
+ function isEvidencedStationAlarm(ctx) {
6408
+ return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
6818
6409
  }
6819
- function gotoPresetCommand(id, ctx) {
6820
- return setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx);
6410
+ var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set([
6411
+ "T8134",
6412
+ "T814X",
6413
+ "T8214",
6414
+ "T8790",
6415
+ "T8122",
6416
+ "T8113-Z",
6417
+ "T8400"
6418
+ ]);
6419
+ function isEvidencedAttachedCamera(ctx) {
6420
+ return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
6821
6421
  }
6822
- function deletePresetCommand(id, ctx) {
6823
- return setJson(PTZ_CMD.PTZ_PRESET_DELETE, { value: id }, ctx);
6422
+ function reportsParam(source, param) {
6423
+ return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
6824
6424
  }
6825
- function previewPresetCommand(id, ctx) {
6826
- return setJson(PTZ_CMD.PTZ_PRESET_PREVIEW, { value: id }, ctx);
6425
+ function hasStandaloneSirenEvidence(source) {
6426
+ return reportsParam(source, SIREN_PARAM.ALARM_VOLUME) && (reportsParam(source, SIREN_PARAM.RING_STATUS) || reportsParam(source, SIREN_PARAM.ALARM_TIMEOUT));
6827
6427
  }
6828
- function presetPicCommand(id, ctx) {
6829
- return setJson(PTZ_CMD.PTZ_PRESET_PIC, { value: id }, ctx);
6428
+ function isSensorCodec(ctx) {
6429
+ return ctx.codec === "sensor";
6830
6430
  }
6831
- function savePresetCommand(id, ctx) {
6832
- return [presetPicCommand(id, ctx), setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx)];
6431
+ function isEvidencedStandaloneSiren(ctx) {
6432
+ return isSensorCodec(ctx) && hasStandaloneSirenEvidence(ctx);
6833
6433
  }
6834
- function setDefaultPositionCommand(presetId, ctx) {
6835
- return setPayload(PTZ_CMD.PTZ_SET_DEFAULT_POSITION, { index: presetId, settingstate: 0 }, ctx);
6434
+ function homeBaseAlarm(seconds, ctx) {
6435
+ if (!ctx.accountName)
6436
+ throw new Error("station-family HomeBase alarm output requires the acting account name");
6437
+ return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
6836
6438
  }
6837
- function parsePresetPoints(reply) {
6838
- const points = reply?.points;
6839
- if (!Array.isArray(points))
6840
- return [];
6841
- const out = [];
6842
- for (const p of points) {
6843
- if (typeof p !== "object" || p === null)
6844
- continue;
6845
- const rec = p;
6846
- const rawId = rec.index ?? rec.id ?? rec.value;
6847
- const id = typeof rawId === "number" ? rawId : Number(rawId);
6848
- if (!Number.isFinite(id))
6849
- continue;
6850
- out.push({ id, raw: rec });
6439
+ var PORTAL_MANUAL_ALARM_TYPE = 10;
6440
+ function cameraAlarm(seconds, ctx) {
6441
+ if (isStation9000({ deviceType: ctx.stationDeviceType })) {
6442
+ if (!ctx.accountName)
6443
+ throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
6444
+ return setPayload(SIREN_CMD.CAMERA_TONE, {
6445
+ time_out: seconds,
6446
+ type: PORTAL_MANUAL_ALARM_TYPE,
6447
+ channel: ctx.channel,
6448
+ username: ctx.accountName
6449
+ }, ctx);
6851
6450
  }
6852
- return out;
6451
+ return {
6452
+ kind: "p2p-int-string",
6453
+ cmd: SIREN_CMD.CAMERA_TONE,
6454
+ value: seconds,
6455
+ valueSub: ctx.channel,
6456
+ channel: ctx.channel
6457
+ };
6853
6458
  }
6854
- function parsePresetImage(reply) {
6855
- const data = reply?.data;
6856
- if (typeof data !== "string" || data.length === 0)
6857
- return void 0;
6858
- const rawIndex = reply?.index;
6859
- const index = typeof rawIndex === "number" ? rawIndex : Number.isFinite(Number(rawIndex)) ? Number(rawIndex) : -1;
6860
- return { index, data };
6459
+ function alarmOutputFamily(ctx) {
6460
+ if (isEvidencedStationAlarm(ctx))
6461
+ return "homebase";
6462
+ if (isEvidencedAttachedCamera(ctx))
6463
+ return "camera";
6464
+ if (isEvidencedStandaloneSiren(ctx))
6465
+ return "standalone";
6466
+ return void 0;
6861
6467
  }
6862
- function isDirection(v) {
6863
- return v === "left" || v === "right" || v === "up" || v === "down";
6468
+ function triggerAlarm(seconds, ctx) {
6469
+ const family = alarmOutputFamily(ctx);
6470
+ if (family === "homebase")
6471
+ return homeBaseAlarm(seconds, ctx);
6472
+ if (family === "camera")
6473
+ return cameraAlarm(seconds, ctx);
6474
+ throw new Error("manual alarm trigger has no verified wire for this device family");
6864
6475
  }
6865
- var PTZ_MEMBERS = {
6866
- /**
6867
- * How fast a rotate STEP travels, on the app's own three-position control: `1` slow, `3` mid, `5`
6868
- * fast. A `scalar` rather than an enum — the endpoints and the midpoint are observed, so 2 and 4 are
6869
- * a prediction of the scale's shape and the wire is not known to reject them.
6870
- *
6871
- * Not the same thing as `rotate`'s `zoom` argument, which scales one step's SIZE rather than its
6872
- * speed.
6873
- *
6874
- * The parameter is **absent until first written**: a camera whose speed has never been set reports
6875
- * no `6015` at all, so the getter is installed only once the value exists — an absent reading is "not
6876
- * configured", never "unsupported".
6877
- */
6878
- rotationSpeed: {
6879
- param: PTZ_CMD.PTZ_ROTATE_SPEED,
6880
- type: "number",
6881
- kind: "scalar",
6882
- provenance: "verified",
6883
- description: "Pan/tilt rotate speed: 1 = slow, 3 = mid, 5 = fast (PTZ_ROTATE_SPEED 6015). \u2705 Write confirmed live on a T8410 (mains, standalone): the app's Slow/Mid/Fast control identified 1/3/5, then 1 and 5 were sent consecutively through this member and read back on the cloud param. 2 and 4 are the scale's shape, not observed values. Absent until first written.",
6884
- write: (v, ctx) => {
6885
- const n = Number(v);
6886
- if (!Number.isInteger(n) || n < 1 || n > 5)
6887
- return void 0;
6888
- return setJson(PTZ_CMD.PTZ_ROTATE_SPEED, { value: n }, ctx);
6889
- }
6890
- },
6891
- /** Rotate a step in a direction; `zoom` scales the step size (1.0 = the default step). */
6892
- rotate: method(({ ctx, sink }) => (direction, zoom = 1) => sink.dispatch(rotateCommand(direction, ctx, zoom)), "Rotate a step in a direction."),
6893
- /** Rotate one step left. */
6894
- left: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("left", ctx)), "Rotate left."),
6895
- /** Rotate one step right. */
6896
- right: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("right", ctx)), "Rotate right."),
6897
- /** Rotate one step up. */
6898
- up: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("up", ctx)), "Rotate up."),
6899
- /** Rotate one step down. */
6900
- down: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("down", ctx)), "Rotate down."),
6901
- /**
6902
- * Zoom rides a SECOND (telephoto) lens, so it is offered only where the device shows zoom evidence —
6903
- * otherwise `dev.ptz()?.zoom` would be a silent no-op on a single-lens pan-tilt camera.
6904
- */
6905
- zoom: method(({ ctx, sink }) => (dstZoom, region) => sink.dispatch(zoomCommand(dstZoom, ctx, region)), "Zoom the telephoto lens.", hasZoomEvidence),
6476
+ function stopAlarm(ctx) {
6477
+ const family = alarmOutputFamily(ctx);
6478
+ if (family === "homebase")
6479
+ return homeBaseAlarm(0, ctx);
6480
+ if (family === "camera")
6481
+ return cameraAlarm(0, ctx);
6482
+ if (family === "standalone")
6483
+ return sirenPayload(SIREN_CMD.MANUAL_STOP, {}, ctx);
6484
+ throw new Error("manual alarm stop has no verified wire for this device family");
6485
+ }
6486
+ function validateTriggerDuration(seconds) {
6487
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) {
6488
+ throw new Error(`trigger duration ${String(seconds)} is not a positive safe whole-number duration in seconds`);
6489
+ }
6490
+ return seconds;
6491
+ }
6492
+ function sirenPayload(cmd, body, ctx) {
6493
+ return setPayload(cmd, { ...body, transaction: String(Date.now()) }, ctx, 0);
6494
+ }
6495
+ var SIREN_MEMBERS = {
6906
6496
  /**
6907
- * The preset sub-API. Write verbs dispatch through the sink; the READ verbs (`list`/`image`) are P2P
6908
- * request/reply and exist only when bound to a media provider — the transport stays
6909
- * capability-agnostic (it runs a generic control-payload query and resolves the correlated notify)
6910
- * while this module owns the sub-command id and the reply parsing.
6911
- *
6912
- * `answers` because calling it performs nothing: it hands back the namespace the verbs live on. Offering
6913
- * it as a control would produce one that returns an object and does nothing.
6497
+ * Authoritative sounding state reported by a standalone siren. HomeBase and camera command
6498
+ * acknowledgements do not install or update this read.
6914
6499
  */
6915
- preset: {
6916
- ...method(({ ctx, sink, media }) => () => {
6917
- const p = {
6918
- goto: (id) => sink.dispatch(gotoPresetCommand(id, ctx)),
6919
- preview: (id) => sink.dispatch(previewPresetCommand(id, ctx)),
6920
- save: async (id) => {
6921
- for (const cmd of savePresetCommand(id, ctx))
6922
- await sink.dispatch(cmd);
6923
- },
6924
- setDefault: (id) => sink.dispatch(setDefaultPositionCommand(id, ctx)),
6925
- delete: (id) => sink.dispatch(deletePresetCommand(id, ctx))
6926
- };
6927
- if (media?.p2pControlQuery) {
6928
- p.list = async (opts) => parsePresetPoints(await media.p2pControlQuery(PTZ_CMD.PTZ_PRESET_QUERY, { value: 0 }, opts));
6929
- p.image = async (id, opts) => parsePresetImage(await media.p2pControlQuery(PTZ_CMD.PTZ_PRESET_PIC, { value: id }, opts));
6930
- }
6931
- return p;
6932
- }, "The preset sub-API: goto/preview/save/setDefault/delete, plus list/image when bound."),
6933
- answers: true
6934
- }
6935
- };
6936
- var PTZ = {
6937
- capability: "ptz",
6938
- description: "Pan/tilt (PTZ) control. Movement is command-driven and rotate speed is stored; position arrives as a live event.",
6500
+ active: {
6501
+ param: SIREN_PARAM.RING_STATUS,
6502
+ property: "siren",
6503
+ type: "bool",
6504
+ kind: "boolean",
6505
+ provenance: "verified",
6506
+ available: isSensorCodec,
6507
+ description: "Whether the siren is sounding (61008 APP_CMD_DEV_RING_STATUS). \u2705 Confirmed boolean: a live test on a T90R0 pushed 1 (sounding) then 0 (silent)."
6508
+ },
6939
6509
  /**
6940
- * The intent route for the movement verbs — kept alongside the member table because these are
6941
- * ARGUMENT-TAKING verbs, not property writes: `rotate` takes a direction, the preset verbs take an id.
6942
- * A member's `write` moves one named value, so it cannot name them, and dropping this would silently
6943
- * remove `setProperty("gotoPreset", 2)` from the low-level path. Each case delegates to the same
6944
- * builder the fluent method uses, so the two cannot drift. Zoom carries the same dual-lens gate the
6945
- * fluent `zoom` member does.
6510
+ * Rejected, not clamped, outside the 1-3 set: the write is fire-and-forget, so an out-of-range level
6511
+ * would look like it worked.
6946
6512
  */
6947
- buildCommand(action, value, ctx) {
6948
- if (action === "rotate" && isDirection(value))
6949
- return rotateCommand(value, ctx);
6950
- if (typeof value === "number") {
6951
- if (action === "zoom")
6952
- return hasZoomEvidence(ctx) ? zoomCommand(value, ctx) : void 0;
6953
- if (action === "gotoPreset")
6954
- return gotoPresetCommand(value, ctx);
6955
- if (action === "previewPreset")
6956
- return previewPresetCommand(value, ctx);
6957
- if (action === "deletePreset")
6958
- return deletePresetCommand(value, ctx);
6959
- if (action === "setDefaultPosition")
6960
- return setDefaultPositionCommand(value, ctx);
6513
+ volume: {
6514
+ param: SIREN_PARAM.ALARM_VOLUME,
6515
+ property: "sirenVolume",
6516
+ type: "number",
6517
+ kind: "scalar",
6518
+ provenance: "verified",
6519
+ available: isSensorCodec,
6520
+ requires: [SIREN_PARAM.ALARM_VOLUME],
6521
+ enumValues: enumLabels(SirenVolume),
6522
+ args: [{ name: "level", kind: "scalar", description: "A device level 1-3 (Low/Mid/High), not a percentage." }],
6523
+ description: "Alarm volume (1825 APP_CMD_SIREN_SENSOR_SET_ALARM_VOL), a device level 1-3 (Low/Mid/High), NOT a percentage. Write verified live on a T90R0 (1350 SET_PAYLOAD {volume}).",
6524
+ write: (v, ctx) => {
6525
+ const level = coerceEnumValue(SirenVolume, v);
6526
+ return level === void 0 ? void 0 : sirenPayload(SIREN_PARAM.ALARM_VOLUME, { volume: level }, ctx);
6961
6527
  }
6962
- return void 0;
6963
6528
  },
6964
- members: PTZ_MEMBERS,
6965
- properties: propertiesOf(PTZ_MEMBERS),
6966
- // SoloCam proves PT via reported preset params (6090/6091/6092/6210) and a rotate command surface
6967
- // (1029), except on a FIXED_LENS_MODELS model; Indoor-PT (deviceType 31/35/111 + C220 range
6968
- // 10008-10011) reports NO PT param, so the vendor deviceType is the only honest signal; explicit
6969
- // PT/S340/S350 model names also prove it.
6970
- // OUTDOOR_PT_CAMERA (48, e.g. SoloCam S340 / T8170) is pan-tilt by definition but a live unit can
6971
- // report none of the preset params — trust the deviceType. The rotating SoloCam solar variant
6972
- // T8124R shares deviceType 62 with the FIXED T8124 base, so its model code is the only honest
6973
- // signal that separates the two. Match ONLY confirmed rotating model codes here — do NOT widen to
6974
- // a `T81\d\dR` wildcard: the "R = rotating" reading is unverified for other models, and an
6975
- // over-broad hint would false-positive a future non-PT model, the same class of bug as a mislabel.
6976
- detection: {
6977
- detect: (record) => !FIXED_LENS_MODELS.has(record.model ?? "") && PRESET_EVIDENCE_PARAMS.some((p) => Object.hasOwn(record.params ?? {}, p)),
6978
- deviceTypes: [
6979
- DeviceType.OUTDOOR_PT_CAMERA,
6980
- DeviceType.INDOOR_PT_CAMERA,
6981
- DeviceType.INDOOR_PT_CAMERA_1080,
6982
- DeviceType.CAMERA_4G_S330,
6983
- DeviceType.INDOOR_PT_CAMERA_C220,
6984
- DeviceType.INDOOR_PT_CAMERA_C210,
6985
- DeviceType.INDOOR_PT_CAMERA_C220_V2,
6986
- DeviceType.INDOOR_PT_CAMERA_C220_V3,
6987
- // T8425 has its own dedicated deviceType (47), distinct from OUTDOOR_PT_CAMERA (48) — easy to
6988
- // miss since the name doesn't say "PT". Was omitted here despite T8425 being the hardware that
6989
- // grounded the rotate_type wire mapping (see PTZ_ROTATE's doc comment) — confirmed live it has
6990
- // no PTZ capability without this, on a real owned unit.
6991
- DeviceType.FLOODLIGHT_CAMERA_8425
6992
- ],
6993
- modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i, /\bT8E00\b/i]
6529
+ /** Only the four captured presets are accepted: no capture supports an arbitrary duration. */
6530
+ alarmDuration: {
6531
+ param: SIREN_PARAM.ALARM_TIMEOUT,
6532
+ type: "number",
6533
+ unit: "s",
6534
+ kind: "seconds",
6535
+ provenance: "verified",
6536
+ available: isSensorCodec,
6537
+ requires: [SIREN_PARAM.ALARM_TIMEOUT],
6538
+ enumValues: enumLabels(SirenAlarmDuration),
6539
+ args: [{ name: "seconds", kind: "seconds", description: "One of the app's presets: 60/300/600/900." }],
6540
+ description: "How long an alarm sounds before stopping itself (61006 APP_CMD_DEV_ALARM_TIMEOUT), seconds. Write verified live on a T90R0 (1350 SET_PAYLOAD {value}); app presets 60/300/600/900.",
6541
+ write: (v, ctx) => {
6542
+ const seconds = coerceEnumValue(SirenAlarmDuration, v);
6543
+ return seconds === void 0 ? void 0 : sirenPayload(SIREN_PARAM.ALARM_TIMEOUT, { value: seconds }, ctx);
6544
+ }
6994
6545
  },
6995
- emits: ["ptzNotify"],
6996
6546
  /**
6997
- * Surface PTZ status the camera streams back while it moves. Two wire shapes (both prove pan-tilt):
6998
- * - SoloCam: `CMD_NOTIFY_PAYLOAD` (1351) JSON `{cmd, payload}` with cmd 6030 (rotate) / 6203 (zoom).
6999
- * - Indoor PT: `CMD_DOORBELL_SET_PAYLOAD` (1700) binary 24-byte records of float `(pan, tilt)`
7000
- * (non-zero while moving; a fixed camera sends zeros).
7001
- * `stationSn` is added by the barrel, so it is NOT included in the returned payload.
6547
+ * Read-only: `apk` provenance means the id comes from the disassembled app and the only live evidence
6548
+ * is a T90R0 reporting 0, which fixes neither the polarity's other value nor a write frame. Typed
6549
+ * `bool` on the app's own naming; do not add a setter until a toggle is captured.
7002
6550
  */
7003
- decodeEvent(signal) {
7004
- if (signal.source !== "p2p-frame")
7005
- return null;
7006
- const f = signal;
7007
- if (f.commandId === PTZ_FRAME.NOTIFY_PAYLOAD && f.json && typeof f.json.cmd === "number" && (f.json.cmd === PTZ_CMD.PTZ_ROTATE || f.json.cmd === PTZ_CMD.PTZ_ZOOM)) {
7008
- return {
7009
- event: "ptzNotify",
7010
- payload: { kind: f.json.cmd === PTZ_CMD.PTZ_ZOOM ? "zoom" : "rotate", payload: f.json.payload }
7011
- };
7012
- }
7013
- if (f.commandId === PTZ_FRAME.CONTROL_PAYLOAD && f.data && f.data.length >= 28) {
7014
- const coords = [];
7015
- for (let o = 4; o + 24 <= f.data.length; o += 24) {
7016
- const pan = f.data.readFloatLE(o + 12);
7017
- const tilt = f.data.readFloatLE(o + 16);
7018
- if (Number.isFinite(pan) && Number.isFinite(tilt) && Math.abs(pan) <= 2 && Math.abs(tilt) <= 2) {
7019
- coords.push([pan, tilt]);
7020
- }
7021
- }
7022
- if (coords.some(([p, t]) => p !== 0 || t !== 0)) {
7023
- return { event: "ptzNotify", payload: { kind: "position", coords } };
7024
- }
6551
+ doNotDisturb: {
6552
+ param: SIREN_PARAM.NOT_DISTURB,
6553
+ type: "bool",
6554
+ kind: "boolean",
6555
+ provenance: "apk",
6556
+ available: isSensorCodec,
6557
+ description: "Do-not-disturb (1828 APP_CMD_SENSOR_NOT_DISTURB). Observed 0 on a T90R0."
6558
+ },
6559
+ /** HomeBase alarm volume is a percentage on a station-scalar wire, unlike standalone level 1-3. */
6560
+ alarmVolume: {
6561
+ param: SIREN_CMD.HUB_SPK_VOLUME,
6562
+ type: "number",
6563
+ unit: "%",
6564
+ kind: "percent",
6565
+ writeOnly: true,
6566
+ provenance: "verified",
6567
+ available: isHomeBase,
6568
+ requires: HUB_ALARM_EVIDENCE,
6569
+ min: 0,
6570
+ max: 100,
6571
+ description: "HomeBase alarm volume 0..100 (1235 CMD_SET_HUB_SPK_VOLUME). Verified audible on a T8030.",
6572
+ write: (v) => setStationScalar(SIREN_CMD.HUB_SPK_VOLUME, Number(v), STATION_CHANNEL)
6573
+ },
6574
+ /** HomeBase alarm tone keeps its independently verified two-option domain. */
6575
+ alarmTone: {
6576
+ param: SIREN_CMD.HUB_ALARM_TONE,
6577
+ property: "hubAlarmTone",
6578
+ type: "number",
6579
+ kind: "enum",
6580
+ enumValues: enumLabels(HubAlarmTone),
6581
+ provenance: "verified",
6582
+ available: isHomeBase,
6583
+ requires: HUB_ALARM_EVIDENCE,
6584
+ args: [{ name: "tone", kind: "enum", description: "One of the app's alarm tones (1-indexed)." }],
6585
+ description: "HomeBase alarm tone selection (1281 APP_CMD_HUB_ALARM_TONE; 1350 SET_PAYLOAD {type}, mValue3 0). Wire verified live on a T8030.",
6586
+ write: (v, ctx) => {
6587
+ const tone = coerceEnumValue(HubAlarmTone, v);
6588
+ return tone === void 0 ? void 0 : setPayload(SIREN_CMD.HUB_ALARM_TONE, { type: tone }, ctx, 0);
7025
6589
  }
7026
- return null;
7027
- }
6590
+ },
6591
+ /** Sound a standalone siren briefly using its dedicated installation-test wire. */
6592
+ test: {
6593
+ action: (ctx) => sirenPayload(SIREN_CMD.ALARM_TEST, {}, ctx),
6594
+ available: isEvidencedStandaloneSiren,
6595
+ description: "Sound the siren briefly as a test."
6596
+ },
6597
+ /** Trigger a verified station-family HomeBase or evidenced attached-camera alarm for a bounded duration. */
6598
+ trigger: {
6599
+ ...method(({ ctx, sink }) => async (seconds) => {
6600
+ await sink.dispatch(triggerAlarm(validateTriggerDuration(seconds), ctx));
6601
+ }, "Trigger the alarm for a positive whole-number duration in seconds.", (ctx) => isEvidencedStationAlarm(ctx) || isEvidencedAttachedCamera(ctx)),
6602
+ args: [{ name: "seconds", kind: "seconds", min: 1, description: "A positive whole-number duration." }]
6603
+ },
6604
+ /** Stop a verified alarm-output family through its own wire contract. */
6605
+ stop: method(({ ctx, sink }) => () => sink.dispatch(stopAlarm(ctx)), "Manually stop a sounding alarm.", (ctx) => alarmOutputFamily(ctx) !== void 0)
7028
6606
  };
7029
-
7030
- // dist/model/capabilities/doorbell.js
7031
- import { setTimeout as sleep } from "node:timers/promises";
7032
- var DOORBELL_MODELS = /* @__PURE__ */ new Set([
7033
- "T820",
7034
- "T8200",
7035
- "T8201",
7036
- "T8202",
7037
- "T8203",
7038
- "T8210",
7039
- "T8212",
7040
- "T8213",
7041
- "T8214",
7042
- "T8220",
7043
- "T8221",
7044
- "T8222"
7045
- ]);
7046
- var DOORBELL_TYPES = [
7047
- DeviceType.DOORBELL,
7048
- DeviceType.BATTERY_DOORBELL,
7049
- DeviceType.BATTERY_DOORBELL_2,
7050
- DeviceType.BATTERY_DOORBELL_PLUS,
7051
- DeviceType.DOORBELL_SOLO,
7052
- DeviceType.BATTERY_DOORBELL_PLUS_E340
7053
- ];
7054
- var DOORBELL_CMD = {
7055
- /**
7056
- * Doorbell "quick response" — make the doorbell PLAY a stored voice reply. Same 136-byte
7057
- * direct-binary shape as the camera setters: `value` = the `voice_id` to play. Reversed from a
7058
- * live capture (outer-cmd 1706, signCode 8, on the doorbell's `device_channel`) + the app's
7059
- * `PlayQuickResponseParser` (`{cmd:1706,voice_id}`).
7060
- */
7061
- QUICK_RESPONSE: 1706,
6607
+ var SIREN = {
6608
+ capability: "siren",
6609
+ description: "Audible alarm output with evidence-bounded state, configuration, test, trigger, and stop members.",
6610
+ members: SIREN_MEMBERS,
6611
+ properties: propertiesOf(SIREN_MEMBERS),
6612
+ /** Detection uses reported evidence and topology; no model string or device type admits a wire. */
6613
+ detection: {
6614
+ detect: (record, codec) => {
6615
+ const homeBase = record.deviceType !== void 0 && HOMEBASE_TYPES.has(record.deviceType);
6616
+ const homeBaseAlarm2 = homeBase && HUB_ALARM_EVIDENCE.some((param) => Object.hasOwn(record.params ?? {}, param));
6617
+ const attachedCameraAlarm = codec === "camera" && !!record.parentSn && hasReportedEasSwitch(record);
6618
+ const standaloneSiren = codec === "sensor" && hasStandaloneSirenEvidence(record);
6619
+ return homeBaseAlarm2 || attachedCameraAlarm || standaloneSiren;
6620
+ }
6621
+ }
6622
+ };
6623
+
6624
+ // dist/model/capabilities/light.js
6625
+ var LIGHT_CMD = {
6626
+ /** Spotlight momentary on/off; `{time,type,value}` under 1700. ✅ Verified via P2P state readback (3/3). */
6627
+ FLOODLIGHT_SWITCH: 1400,
7062
6628
  /**
7063
- * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box,
7064
- * the app's "existing doorbell chime" (as opposed to / in addition to the HomeBase acting as the chime,
7065
- * param 1702 `chimeSwitch` above; toggling each switch alone wrote one parameter and named the pair). Same
7066
- * 136-byte direct-binary shape as `QUICK_RESPONSE`/camera on-off: `[u32 channel][u32 value][account_id
7067
- * ASCII, zero-padded to 128 bytes]`, outer P2P cmd = 1703 itself, signCode 8, on the device's own
7068
- * channel. Reversed from a live capture (outer-cmd 1703, signCode 8, captured on the doorbell's
7069
- * `device_channel` — channel 2 for that unit, NOT a wire constant, always use `ctx.channel`):
7070
- * turning ON sent `value=1`, turning OFF sent `value=0` — a plain boolean, 1=on/0=off.
7071
- * App `APP_CMD_BAT_DOORBELL_MECHANICAL_CHIME_SWITCH`.
6629
+ * Spotlight brightness 1..100. ✅ SOLVED & verified live (T8425 + T8124): direct-binary command,
6630
+ * 136-byte struct `[u32 ch][u32 value][account_id pad→128]` (value = raw 1..100), NOT a JSON wrapper.
7072
6631
  */
7073
- MECHANICAL_CHIME_SWITCH: 1703,
6632
+ SPOTLIGHT_BRIGHTNESS: 1401,
6633
+ /** Spotlight master enable — distinct from the on/off switch. ✅ Verified live T8124 (same 136-byte direct-binary struct as brightness). */
6634
+ SPOTLIGHT_ENABLE: 1403,
6635
+ /** Spotlight color temperature 0=warm..100=cool. ✅ Verified live T8124 (only tunable-white spotlights respond; T8425 is white-only). */
6636
+ SPOTLIGHT_COLOR_TEMP: 1410,
7074
6637
  /**
7075
- * Whether the HOMEBASE acts as the doorbell's chime — the app's "HomeBase as chime" switch, as opposed
7076
- * to the wired chime box {@link DOORBELL_CMD.MECHANICAL_CHIME_SWITCH} drives. The app constant reads
7077
- * `CHIME_SWITCH`, which is why this was long described as a separate plug-in chime; toggling that one
7078
- * switch alone on a T8210 behind a HomeBase wrote 1702 and nothing else, and the account has no
7079
- * plug-in chime at all. Same 136-byte direct-binary shape as
7080
- * that sibling: `[u32 channel][u32 value][account_id ASCII, zero-padded to 128 bytes]`, outer P2P cmd
7081
- * 1702 itself, signCode 8, on the device's own channel. Reversed from a live capture (outer-cmd 1702,
7082
- * signCode 8, on the doorbell's `device_channel` — 2 for that unit, NOT a wire constant, always use
7083
- * `ctx.channel`): turning OFF sent `value=0`, turning ON sent `value=1` — a plain boolean.
7084
- * App `APP_CMD_BAT_DOORBELL_CHIME_SWITCH`.
6638
+ * Auto-spotlight = "motion-activated light" — light up when motion fires. ✅ Wire reversed from a live
6639
+ * OUTBOUND P2P capture + replay-verified on T8425 (ch3, 2026-07-13, ON+OFF): the real command
6640
+ * is **cmd 1422**, carried in a `1350` SET_PAYLOAD envelope on the DEVICE channel (mChannel = device
6641
+ * channel, mValue3 0), with a COMPOSITE payload — NOT a bare bool:
6642
+ * `{brightness:0..100, enable:0|1, latitude:"", longtitude:"" (sic), mode:1, schedule:[], sunset2rise:0, time}`
6643
+ * `enable` is the on/off; `time` = auto-off seconds; `mode`/`schedule`/`sunset2rise` are the scheduling
6644
+ * knobs. See {@link AutoSpotlightOptions}.
6645
+ * NB: the app-JS constant `APP_CMD_SET_LIGHT_CTRL_PIR_SWITCH` (1408) is a RED HERRING for this device —
6646
+ * the T8425 never emits 1408; three 1408 wire attempts all no-op'd. (No `1408` constant is defined — it's
6647
+ * dead; do not add one.)
7085
6648
  */
7086
- CHIME_SWITCH: 1702,
6649
+ MOTION_ACTIVATE_LIGHT: 1422
6650
+ };
6651
+ var BRIGHTNESS_MIN = 1;
6652
+ var BRIGHTNESS_MAX = 100;
6653
+ function autoSpotlightCommand(on, ctx, opts = {}) {
6654
+ return setPayload(LIGHT_CMD.MOTION_ACTIVATE_LIGHT, {
6655
+ brightness: clamp(Number(opts.brightness ?? 50), BRIGHTNESS_MIN, BRIGHTNESS_MAX),
6656
+ enable: on ? 1 : 0,
6657
+ latitude: "",
6658
+ longtitude: "",
6659
+ mode: opts.mode ?? 1,
6660
+ schedule: [],
6661
+ sunset2rise: 0,
6662
+ time: opts.time ?? 30
6663
+ }, ctx, 0, void 0, "auto");
6664
+ }
6665
+ function lightSwitchWire(ctx) {
6666
+ const t = ctx.deviceType;
6667
+ if (t === void 0)
6668
+ return void 0;
6669
+ if (LIGHT_SWITCH_JSON_TYPES.has(t))
6670
+ return "json";
6671
+ if (LIGHT_SWITCH_INT_STRING_TYPES.has(t))
6672
+ return "int-string";
6673
+ return void 0;
6674
+ }
6675
+ var LIGHT_SWITCH_JSON_TYPES = /* @__PURE__ */ new Set([
6676
+ DeviceType.FLOODLIGHT_CAMERA_8425,
6677
+ ...WALL_LIGHT_TYPES,
6678
+ DeviceType.INDOOR_PT_CAMERA,
6679
+ DeviceType.INDOOR_PT_CAMERA_1080,
6680
+ DeviceType.CAMERA_4G_S330
6681
+ ]);
6682
+ var LIGHT_SWITCH_INT_STRING_TYPES = /* @__PURE__ */ new Set([
6683
+ DeviceType.FLOODLIGHT_CAMERA_8423,
6684
+ DeviceType.INDOOR_OUTDOOR_CAMERA_1080P_NO_LIGHT,
6685
+ DeviceType.INDOOR_OUTDOOR_CAMERA_2K,
6686
+ DeviceType.INDOOR_OUTDOOR_CAMERA_1080P,
6687
+ DeviceType.OUTDOOR_PT_CAMERA,
6688
+ DeviceType.CAMERA2C,
6689
+ DeviceType.CAMERA2,
6690
+ DeviceType.CAMERA2_PRO,
6691
+ DeviceType.CAMERA2C_PRO,
6692
+ DeviceType.CAMERA3,
6693
+ DeviceType.CAMERA3C,
6694
+ DeviceType.PROFESSIONAL_247
6695
+ ]);
6696
+ function switchCommand(on, ctx) {
6697
+ switch (lightSwitchWire(ctx)) {
6698
+ case "json":
6699
+ return setJson(LIGHT_CMD.FLOODLIGHT_SWITCH, { time: 0, type: 2, value: on ? 1 : 0 }, ctx);
6700
+ case "int-string":
6701
+ return setScalar(LIGHT_CMD.FLOODLIGHT_SWITCH, on ? 1 : 0, ctx, "int-string");
6702
+ default:
6703
+ return void 0;
6704
+ }
6705
+ }
6706
+ function directBinary(param, value, ctx) {
6707
+ return setScalar(param, value, ctx, "direct-binary");
6708
+ }
6709
+ function switchFrame(on, ctx) {
6710
+ const cmd = switchCommand(on, ctx);
6711
+ if (!cmd)
6712
+ throw new Error(`light: unknown spotlight switch wire format \u2014 confirm it [${describeDevice(ctx)}]`);
6713
+ return cmd;
6714
+ }
6715
+ var LIGHT_MEMBERS = {
7087
6716
  /**
7088
- * Wide Dynamic Range (WDR) image switch — a video/image tone-mapping setting that widens the
7089
- * exposure range in high-contrast scenes (bright sky behind a visitor, etc). SAME 136-byte
7090
- * direct-binary shape/polarity/capture session as `MECHANICAL_CHIME_SWITCH` immediately above: outer
7091
- * P2P cmd 1704, signCode 8, device channel, `value` 1=on/0=off.
7092
- * App `APP_CMD_BAT_DOORBELL_WDR_SWITCH`.
6717
+ * Whether the lamp is lit RIGHT NOW — the momentary switch, not a setting. Measured on a T8170: 1400
6718
+ * goes to `1` when a client lights the spotlight and back to `0` when that client stops, so the value
6719
+ * tracks whoever is streaming rather than a preference anyone set. The vendor app lights the lamp for a
6720
+ * live view and drops it on quitting.
6721
+ *
6722
+ * So it reads on whenever any client is watching. The setting a user changes and expects to persist
6723
+ * is {@link spotlightEnabled}.
6724
+ *
6725
+ * The switch refuses for a reason a value check cannot give — this model's frame SHAPE is unconfirmed —
6726
+ * so `switchFrame` throws that reason and `bindMembers` turns it into the rejection, rather than
6727
+ * letting the generated "not a valid value" blame a boolean that was never the problem.
7093
6728
  */
7094
- WDR_SWITCH: 1704,
6729
+ isOn: {
6730
+ param: LIGHT_CMD.FLOODLIGHT_SWITCH,
6731
+ property: "light",
6732
+ type: "bool",
6733
+ kind: "boolean",
6734
+ provenance: "verified",
6735
+ description: "Light on/off (1400) \u2014 lit right now, not a setting: it follows whichever client is streaming. Verified via P2P state readback (3/3); session-scoped behaviour measured live on a T8170.",
6736
+ write: (v, ctx) => switchFrame(asBool(v), ctx),
6737
+ writeAs: "set",
6738
+ aliases: { on: true, off: false }
6739
+ },
7095
6740
  /**
7096
- * Doorbell chime ("ding-dong") volume — app `APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME`. UNLIKE the 1703/1704 direct-binary switches above, this rides the
7097
- * `SET_PAYLOAD` (1350) JSON envelope: `{account_id,cmd:1717,mChannel:<deviceCh>,mValue3:0,
7098
- * payload:{dingdong_volume:N}}`, GCM signCode 8, on the doorbell's `device_channel` (2 for the
7099
- * captured unit — always use `ctx.channel`, don't hardcode). `mValue3` is an EXPLICIT `0`, not the
7100
- * `SET_PAYLOAD` default of `mValue3:cmd` (see {@link module:./intent setPayload}'s doc comment) —
7101
- * both live frames captured showed `mValue3:0`. Reversed from a live capture (values 3 and 25
7102
- * observed) — a 0-100 loudness level, distinct from the already-shipped `ringtoneVolume` (1708,
7103
- * direct-binary, `audio.ts`), which is a DIFFERENT control.
6741
+ * Manual level for the spotlight, 1-100 — the floor is 1 rather than 0 because a 0 reads as "off" and
6742
+ * that is `isOn`'s job. The write is pinned to the level-2 direct-binary frame (`directBinary`),
6743
+ * the only shape captured for it, so a standalone camera that never negotiates a level-2 key cannot
6744
+ * set it even though the getter reads fine.
7104
6745
  */
7105
- DINGDONG_VOLUME: 1717,
6746
+ brightness: {
6747
+ param: LIGHT_CMD.SPOTLIGHT_BRIGHTNESS,
6748
+ type: "number",
6749
+ unit: "%",
6750
+ kind: "percent",
6751
+ provenance: "verified",
6752
+ min: BRIGHTNESS_MIN,
6753
+ max: BRIGHTNESS_MAX,
6754
+ description: "Manual brightness, range 1-100 (1401). Verified live (T8425 + T8124).",
6755
+ write: (v, ctx) => directBinary(LIGHT_CMD.SPOTLIGHT_BRIGHTNESS, Number(v), ctx)
6756
+ },
7106
6757
  /**
7107
- * Doorbell chime ("ding-dong") RINGTONE SELECTION — app `APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE`.
7108
- * SAME `SET_PAYLOAD` (1350) JSON envelope shape as
7109
- * `DINGDONG_VOLUME` immediately above: `{account_id,cmd:1718,mChannel:<deviceCh>,mValue3:0,
7110
- * payload:{dingdong_ringtone:N}}`, GCM signCode 8, doorbell `device_channel`, explicit `mValue3:0`.
7111
- * Reversed from a live capture (values 4 and 0 observed) — `N` is an INTEGER INDEX picking one of
7112
- * several stored chime tones, NOT a boolean.
6758
+ * Warm-to-cool on a 0-100 scale — a `scalar`, not a percentage or a mired value, since 0 is one end
6759
+ * of a range rather than "none". `writeOnly`, so there is a setter and no getter at all: the device
6760
+ * accepts the setting and never reports it back.
6761
+ * Only tunable-white spotlights respond; a white-only model accepts the frame and does nothing.
7113
6762
  */
7114
- DINGDONG_RINGTONE: 1718,
6763
+ colorTemp: {
6764
+ param: LIGHT_CMD.SPOTLIGHT_COLOR_TEMP,
6765
+ type: "number",
6766
+ kind: "scalar",
6767
+ provenance: "verified",
6768
+ writeOnly: true,
6769
+ min: 0,
6770
+ max: 100,
6771
+ description: "Colour temperature, 0 (warm) to 100 (cool). Not reported back, so it is write-only.",
6772
+ write: (v, ctx) => directBinary(LIGHT_CMD.SPOTLIGHT_COLOR_TEMP, Number(v), ctx)
6773
+ },
7115
6774
  /**
7116
- * Live-view quality AND the video encoding format, both in one integer — app
7117
- * `CMD_BAT_DOORBELL_VIDEO_QUALITY`. The value is `quality + (highCompression ? 5 : 0)`, where quality
7118
- * is 0 Auto / 1 Low / 2 Medium / 3 High ({@link DoorbellVideoQuality}). The app drives the two from
7119
- * separate menus and each write carries both, so setting either requires the other's current value —
7120
- * which is why neither is a derived setter.
6775
+ * The master enable, one level above {@link isOn}: this decides whether the spotlight may light at all,
6776
+ * where `isOn` is the momentary switch. `writeAs` names the setter `setEnabled` rather than the
6777
+ * `setSpotlightEnabled` the key would derive, since the capability is already the spotlight.
7121
6778
  *
7122
- * The encoding is reversed from writing every option in the app and reading the parameter back: High
7123
- * on high compression is 8 and on low compression 3, Low on low compression 1, Auto on high
7124
- * compression 5. Six of the eight combinations were observed that way; `0` (Auto, low compression) and
7125
- * `2` (Medium, low compression) follow the same arithmetic but were not individually written.
6779
+ * Reported, so it is a read as well as a write. ✅ Verified live on a T8170: the cloud device list
6780
+ * carries 1403, and its value tracks the vendor app's spotlight setting in both directions — `1` to `0`
6781
+ * when the setting is switched off, `0` to `1` when it is switched back on, so the polarity is direct
6782
+ * and 1 means enabled. The poll reports a param only when its PREVIOUS value differed, so the id is in
6783
+ * the record rather than newly appearing. The param dictionary names it `floodlightTotalSwitch`
6784
+ * (`app:FLOODLIGHT_TOTAL_SWITCH`) and lists the T8170 among its models.
7126
6785
  *
7127
- * Wire captured byte-exact from the app on a T8210 behind a HomeBase: outer P2P cmd 1705 itself — no
7128
- * wrapper — signCode 8, on the doorbell's own channel, the same 136-byte direct-binary
7129
- * `[u32 channel][u32 value][account_id ASCII pad 128]` struct as its 1703/1704 siblings. The station
7130
- * answers with an unencrypted level-1 frame carrying 0.
6786
+ * This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
6787
+ * the lamp being lit right now, driven by whichever
6788
+ * client is streaming — the vendor app lights it for a live view and drops it on quitting.
7131
6789
  *
7132
- * Replayed from this SDK and confirmed on that device: three writes in one session, each read back off
7133
- * the parameter — quality alone, then the encoding format with the quality held, then a restore.
6790
+ * The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
6791
+ * unit.
7134
6792
  */
7135
- VIDEO_QUALITY: 1705,
6793
+ spotlightEnabled: {
6794
+ param: LIGHT_CMD.SPOTLIGHT_ENABLE,
6795
+ type: "bool",
6796
+ kind: "boolean",
6797
+ provenance: "verified",
6798
+ description: "The spotlight master switch, distinct from the on/off above \u2014 whether the spotlight may light at all. Read verified live on a T8170 (param 1403, direct polarity: 1 = enabled).",
6799
+ write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
6800
+ writeAs: "setEnabled"
6801
+ }
6802
+ };
6803
+ var LIGHT = {
6804
+ capability: "light",
6805
+ description: "Floodlight / spotlight manual switch and brightness.",
6806
+ members: LIGHT_MEMBERS,
6807
+ properties: propertiesOf(LIGHT_MEMBERS),
6808
+ // Three T8171 units on firmware 1.2.1.6 report 1400/1401/1403 without a spotlight, so those
6809
+ // param ids are not sufficient evidence for that model. The camera status LED (1045) is not light.
6810
+ // Other models keep the existing param and name evidence.
6811
+ detection: {
6812
+ modelHints: [/floodlight|wall.?light|spot.?light|search.?light/i],
6813
+ detect: (record) => record.model !== "T8171" && [1400, 1401, 1403, 6080, 6082, 1413].some((param) => Object.hasOwn(record.params ?? {}, param))
6814
+ },
7136
6815
  /**
7137
- * Doorbell quick-response LIST fetch — the sub-command carried inside a `SET_PAYLOAD` (1350)
7138
- * wrapper (app `APP_CMD_GET_CUSTOMIZE_VICOE_LIST`). The station replies with a `NOTIFY_PAYLOAD`
7139
- * (1351) frame whose JSON is `{cmd:6237, payload:{voice_list:[{voice_id,voice_name,voice_path}]}}`.
7140
- * Reversed from the app's `GetQuickResponseListP2pParser` + the capture heap. NOTE: 6238
7141
- * (`APP_CMD_GET_QUICK_RESPONSE`) is the single-file DOWNLOAD, not the list.
6816
+ * Only what the member table cannot express: `on`/`off` as no-argument verbs over the same wire as the
6817
+ * derived `set`, and the write-only auto-spotlight composite. The verbs stay UNDESCRIBED on purpose —
6818
+ * `set` is the method that takes the value, and describing all three would offer one state as three
6819
+ * controls. `async` is what turns `switchFrame`'s throw into the rejection a `Promise<void>`
6820
+ * caller expects, the same shape the derived setter gets from `bindMembers`.
7142
6821
  */
7143
- GET_QUICK_RESPONSE_LIST: 6237
6822
+ actions({ ctx, sink }) {
6823
+ const dispatchSwitch = async (on) => sink.dispatch(switchFrame(on, ctx));
6824
+ return {
6825
+ on: () => dispatchSwitch(true),
6826
+ off: () => dispatchSwitch(false),
6827
+ setAutoSpotlight: (on, opts) => sink.dispatch(autoSpotlightCommand(asBool(on), ctx, opts))
6828
+ };
6829
+ }
7144
6830
  };
7145
- var DoorbellRingtone = {
7146
- Default: 0,
7147
- Silent: 1,
7148
- Beacon: 2,
7149
- Chord: 3,
7150
- Christmas: 4,
7151
- Circuit: 5,
7152
- Clock: 6,
7153
- Ding: 7,
7154
- Hillside: 8,
7155
- Presto: 9
6831
+
6832
+ // dist/model/capabilities/smart-light.js
6833
+ var LIGHT_MQTT_CMD = 17;
6834
+ var LIGHT_MQTT_REPORT_CMD = 16;
6835
+ var LIGHT_CMD2 = {
6836
+ /** `getDeviceInfoPayloadData` — request a full status report. */
6837
+ GET_DEVICE_INFO: 512,
6838
+ /** `setDeviceInfoPayloadData` — on/off (tag 0xa3) + brightness (tag 0xa4). */
6839
+ SET_DEVICE_INFO: 513,
6840
+ /** The device→app status report (`event_device_status_notify`), sent unsolicited. */
6841
+ DEVICE_STATUS_REPORT: 516,
6842
+ /** `setLightEffectParamsPayloadData` — apply one plain custom colour to evidenced segments. */
6843
+ SET_LIGHT_EFFECT_PARAMS: 518,
6844
+ /** The device's answer to {@link LIGHT_CMD.GET_DEVICE_INFO}. */
6845
+ GET_DEVICE_INFO_REPLY: 2560,
6846
+ /** `setLightEffectParamsV2PayloadData` — select a gallery effect by catalog id. WIRED (see doc). */
6847
+ SET_LIGHT_EFFECT_PARAMS_V2: 525
7156
6848
  };
7157
- var DoorbellVideoQuality = {
7158
- /** The doorbell picks a quality from the link. */
7159
- Auto: 0,
7160
- Low: 1,
7161
- Medium: 2,
7162
- High: 3
6849
+ var GET_DEVICE_INFO_ALL = 8191;
6850
+ var REQUEST_STATUS_TAG = 163;
6851
+ var WRITE_TAG = { IS_ON: 163, LEVEL: 164 };
6852
+ var LIGHT_PARAM = {
6853
+ POWER: 161,
6854
+ BRIGHTNESS: 162,
6855
+ LENGTH: 163,
6856
+ EFFECT_ID: 164,
6857
+ COLOR_GRADIENT: 165,
6858
+ CLOUD_EFFECT_ID: 166,
6859
+ EFFECT_MODE: 167
7163
6860
  };
7164
- function composite(raw) {
7165
- if (typeof raw !== "number" && (typeof raw !== "string" || raw.trim() === ""))
6861
+ var COMMON_TAGS = /* @__PURE__ */ new Map([
6862
+ [161, LIGHT_PARAM.POWER],
6863
+ [162, LIGHT_PARAM.BRIGHTNESS],
6864
+ [163, LIGHT_PARAM.LENGTH],
6865
+ [164, LIGHT_PARAM.EFFECT_ID],
6866
+ [165, LIGHT_PARAM.COLOR_GRADIENT],
6867
+ [166, LIGHT_PARAM.CLOUD_EFFECT_ID]
6868
+ ]);
6869
+ var MAX_NUMERIC_LEN = 4;
6870
+ var FRAME_LAYOUTS = /* @__PURE__ */ new Map([
6871
+ [LIGHT_CMD2.DEVICE_STATUS_REPORT, { modeTag: 167 }],
6872
+ [LIGHT_CMD2.GET_DEVICE_INFO_REPLY, { modeTag: 168 }]
6873
+ ]);
6874
+ var BOOL_PARAMS = /* @__PURE__ */ new Set([LIGHT_PARAM.POWER, LIGHT_PARAM.COLOR_GRADIENT]);
6875
+ function reportFrom(signal) {
6876
+ if (signal.source !== "mqtt" || !signal.frame)
7166
6877
  return void 0;
7167
- const v = Number(raw);
7168
- return Number.isInteger(v) && v >= 0 && v <= 8 && v % 5 <= 3 ? v : void 0;
6878
+ const { envelopeCmd, cmd, fields } = signal.frame;
6879
+ if (envelopeCmd !== LIGHT_MQTT_REPORT_CMD)
6880
+ return void 0;
6881
+ const layout = FRAME_LAYOUTS.get(cmd);
6882
+ if (!layout)
6883
+ return void 0;
6884
+ const params = {};
6885
+ for (const { tag: tag2, value } of fields) {
6886
+ const param = tag2 === layout.modeTag ? LIGHT_PARAM.EFFECT_MODE : COMMON_TAGS.get(tag2);
6887
+ if (param === void 0 || value.length === 0 || value.length > MAX_NUMERIC_LEN)
6888
+ continue;
6889
+ const n = value.readUIntLE(0, value.length);
6890
+ params[param] = BOOL_PARAMS.has(param) ? n === 1 ? "1" : "0" : String(n);
6891
+ }
6892
+ return Object.keys(params).length ? params : void 0;
7169
6893
  }
7170
- function parseQuickResponses(voiceList) {
7171
- return (voiceList ?? []).map((v) => {
7172
- const path = typeof v.voice_path === "string" ? v.voice_path : "";
7173
- return {
7174
- voiceId: Number(v.voice_id),
7175
- name: typeof v.voice_name === "string" ? v.voice_name : "",
7176
- path,
7177
- custom: !path.startsWith("/system/snd/")
7178
- };
7179
- });
6894
+ var BRIGHTNESS_MIN2 = 0;
6895
+ var BRIGHTNESS_MAX2 = 100;
6896
+ function deviceInfoCommand(opts) {
6897
+ const fields = [];
6898
+ if (opts.isOn !== void 0)
6899
+ fields.push({ tag: WRITE_TAG.IS_ON, value: Buffer.from([opts.isOn ? 1 : 0]) });
6900
+ if (opts.level !== void 0) {
6901
+ fields.push({
6902
+ tag: WRITE_TAG.LEVEL,
6903
+ value: Buffer.from([clamp(Math.round(opts.level), BRIGHTNESS_MIN2, BRIGHTNESS_MAX2)])
6904
+ });
6905
+ }
6906
+ return { kind: "mqtt-dp", mqttCmdCode: LIGHT_MQTT_CMD, cmdCode: LIGHT_CMD2.SET_DEVICE_INFO, fields };
7180
6907
  }
7181
- var DOORBELL_MEMBERS = {
6908
+ function requestStateCommand() {
6909
+ const value = Buffer.alloc(4);
6910
+ value.writeUInt32LE(GET_DEVICE_INFO_ALL, 0);
6911
+ return {
6912
+ kind: "mqtt-dp",
6913
+ mqttCmdCode: LIGHT_MQTT_CMD,
6914
+ cmdCode: LIGHT_CMD2.GET_DEVICE_INFO,
6915
+ fields: [{ tag: REQUEST_STATUS_TAG, value }]
6916
+ };
6917
+ }
6918
+ function presetCommand(lightId) {
6919
+ return {
6920
+ kind: "mqtt-dp-preset",
6921
+ mqttCmdCode: LIGHT_MQTT_CMD,
6922
+ cmdCode: LIGHT_CMD2.SET_LIGHT_EFFECT_PARAMS_V2,
6923
+ companionCmdCode: LIGHT_CMD2.SET_DEVICE_INFO,
6924
+ presetId: lightId
6925
+ };
6926
+ }
6927
+ var CONFIRMED_EFFECT_MODELS = /* @__PURE__ */ new Set(["T8L02"]);
6928
+ var CONFIRMED_COLOR_MODELS = /* @__PURE__ */ new Set(["T8L02"]);
6929
+ var MAX_COLOR_SEGMENTS = 254;
6930
+ var modelKey = (model) => (model ?? "").trim().toUpperCase();
6931
+ function colorCommand(color, segmentCount) {
6932
+ return {
6933
+ kind: "mqtt-dp-color",
6934
+ mqttCmdCode: LIGHT_MQTT_CMD,
6935
+ cmdCode: LIGHT_CMD2.SET_LIGHT_EFFECT_PARAMS,
6936
+ ...color,
6937
+ segmentCount
6938
+ };
6939
+ }
6940
+ var SMART_LIGHT_MEMBERS = {
7182
6941
  /**
7183
- * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
7184
- * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
7185
- * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
7186
- * inferred from the shared param range.
6942
+ * On/off for the whole run of lights. `writeAs` names the setter `set` rather than `setPower`, and the
6943
+ * `on`/`off` verbs beside it in `actions()` reach the same frame. `realtime` like every read here:
6944
+ * there is no pollable cloud param, so the getter answers `undefined` until the first MQTT report —
6945
+ * `refreshState` is how a caller populates it on demand. Note the READ tag (0xa1) and the WRITE tag
6946
+ * (0xa3) differ; the two legs' tag maps are unrelated.
7187
6947
  */
7188
- chimeSwitch: {
7189
- param: DOORBELL_CMD.CHIME_SWITCH,
6948
+ power: {
6949
+ param: LIGHT_PARAM.POWER,
6950
+ property: "lightPower",
7190
6951
  type: "bool",
7191
6952
  kind: "boolean",
7192
6953
  provenance: "verified",
7193
- description: `The HomeBase plays the doorbell's chime (1702; the app's "HomeBase as chime" switch \u2014 write wire-confirmed live on T8210).`,
7194
- write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6954
+ realtime: true,
6955
+ description: "Power state, DP tag 0xa1. \u2705 Source-confirmed in the app's own report parser, then live-decoded off a T8L02 status report.",
6956
+ write: (v) => deviceInfoCommand({ isOn: asBool(v) }),
6957
+ writeAs: "set",
6958
+ aliases: { on: true, off: false }
7195
6959
  },
7196
6960
  /**
7197
- * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
7198
- * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
6961
+ * The CONFIGURED level, not the live output: it persists across an off, so reading a non-zero
6962
+ * brightness says nothing about whether the lights are lit — pair it with `power`. 0 is a legal level
6963
+ * here (unlike the camera spotlight's 1-100), and the write clamps into 0-100 rather than refusing.
6964
+ * `realtime`, so `undefined` until the first report.
7199
6965
  */
7200
- mechanicalChimeSwitch: {
7201
- param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
7202
- type: "bool",
7203
- kind: "boolean",
6966
+ brightness: {
6967
+ param: LIGHT_PARAM.BRIGHTNESS,
6968
+ property: "lightBrightness",
6969
+ type: "number",
6970
+ unit: "%",
6971
+ kind: "percent",
7204
6972
  provenance: "verified",
7205
- description: `The existing wired chime box rings (1703; the app's "existing doorbell chime" switch \u2014 confirmed on T8214, and that pairing confirmed by toggling it alone on a T8210).`,
7206
- write: (v, ctx) => setScalar(DOORBELL_CMD.MECHANICAL_CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6973
+ realtime: true,
6974
+ min: BRIGHTNESS_MIN2,
6975
+ max: BRIGHTNESS_MAX2,
6976
+ description: "Brightness 0-100, DP tag 0xa2. Persists across off \u2014 it is the configured level, not the live output. \u2705 Source-confirmed in the app's own report parser, then echoed back the value written, live.",
6977
+ write: (v) => deviceInfoCommand({ level: Number(v) })
7207
6978
  },
7208
- /** Same reasoning and the same capture session as {@link DOORBELL_MEMBERS.mechanicalChimeSwitch}. */
7209
- wdrSwitch: {
7210
- param: DOORBELL_CMD.WDR_SWITCH,
6979
+ /**
6980
+ * How many individually addressable segments the installed run has — a physical fact of the strip,
6981
+ * which is why it is read-only. A bare `scalar` with no unit: the wire says how many, not how long.
6982
+ * `realtime`, so `undefined` until the first report.
6983
+ */
6984
+ lightLength: {
6985
+ param: LIGHT_PARAM.LENGTH,
6986
+ type: "number",
6987
+ kind: "scalar",
6988
+ provenance: "verified",
6989
+ realtime: true,
6990
+ description: "Addressable segment count, DP tag 0xa3."
6991
+ },
6992
+ /**
6993
+ * The gallery effect SELECTED — an `identifier`, not an enum, because it names an entry in a catalog
6994
+ * that only exists at runtime, so there is no option set to publish. Distinct from `cloudEffectId`,
6995
+ * which is the one actually running. Written by `setEffect`, whose gate is the model rather than a
6996
+ * value. `realtime`, so `undefined` until the first report.
6997
+ */
6998
+ effectId: {
6999
+ param: LIGHT_PARAM.EFFECT_ID,
7000
+ property: "lightEffectId",
7001
+ type: "number",
7002
+ kind: "identifier",
7003
+ provenance: "verified",
7004
+ realtime: true,
7005
+ description: "Selected gallery effect id, DP tag 0xa4."
7006
+ },
7007
+ /**
7008
+ * Whether the run blends between an effect's colours rather than stepping between them. Read-only:
7009
+ * the report parser establishes the flag, but no write frame for it has been reversed, so no setter
7010
+ * is offered. One of the two params surfaced as `"1"`/`"0"` so the `bool` narrowing reads it.
7011
+ * `realtime`, so `undefined` until the first report.
7012
+ */
7013
+ colorGradient: {
7014
+ param: LIGHT_PARAM.COLOR_GRADIENT,
7015
+ property: "lightColorGradient",
7211
7016
  type: "bool",
7212
7017
  kind: "boolean",
7213
7018
  provenance: "verified",
7214
- description: "Wide Dynamic Range (WDR) image switch (1704; write wire-confirmed live on T8214 \u2014 see DOORBELL_CMD.WDR_SWITCH).",
7215
- write: (v, ctx) => setScalar(DOORBELL_CMD.WDR_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
7019
+ realtime: true,
7020
+ description: "Colour-gradient switch, DP tag 0xa5."
7216
7021
  },
7217
7022
  /**
7218
- * The READ half of a control whose write lives on `audio` — hence `writtenElsewhere`, which is what
7219
- * keeps the published schema's `writable` true even though this member declares no `write`. The split
7220
- * is deliberate: `audio` owns every volume wire, but 1708 leaks onto non-doorbell cameras, so `audio`
7221
- * gates its setter on this capability while the read stays here where it is doorbell-only. Distinct
7222
- * from `dingdongVolume` (1717), which is a different control on a different wire.
7023
+ * The effect actually RUNNING, `0` when none is — so this, not `effectId`, is the read that answers
7024
+ * "is an effect playing". An `identifier` for the same reason as its sibling: the catalog it indexes
7025
+ * is fetched at runtime. Read-only; `setEffect` drives the selection. `realtime`, so `undefined`
7026
+ * until the first report.
7223
7027
  */
7224
- ringtoneVolume: {
7225
- param: 1708,
7028
+ cloudEffectId: {
7029
+ param: LIGHT_PARAM.CLOUD_EFFECT_ID,
7030
+ property: "lightCloudEffectId",
7226
7031
  type: "number",
7227
- unit: "%",
7228
- kind: "percent",
7032
+ kind: "identifier",
7229
7033
  provenance: "verified",
7230
- writtenElsewhere: true,
7231
- description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
7034
+ realtime: true,
7035
+ description: "Running gallery effect id (0 when off), DP tag 0xa6."
7036
+ },
7037
+ /**
7038
+ * Reported in every status frame, so it belongs in the schema and answers through `getProperty` — but
7039
+ * given no typed getter: the app's own parser reads 0xa7 differently on the notify and on the
7040
+ * get-reply, and no capture settles which value means what.
7041
+ */
7042
+ lightEffectMode: {
7043
+ param: LIGHT_PARAM.EFFECT_MODE,
7044
+ type: "number",
7045
+ kind: "scalar",
7046
+ provenance: "apk",
7047
+ unexposed: true,
7048
+ description: "Effect mode, DP tag 0xa7. Reported, but its value space is not evidenced \u2014 no typed getter, and a bare mode index rather than a named set, since the options it selects among are unknown."
7232
7049
  },
7233
7050
  /**
7234
- * A `1350` SET_PAYLOAD envelope (NOT direct-binary) with an explicit `mValue3` 0 — both live frames
7235
- * captured that value. Bounded 0-100 like every sibling volume setter: the write is fire-and-forget,
7236
- * so an out-of-range value would otherwise be sent as-is and look like it worked. `provenance` is
7237
- * "verified" on our own capture, not merely an observed param id.
7051
+ * A request, not a state: reports are pushed on change with no periodic heartbeat, so this is how a
7052
+ * caller populates the getters on demand. The reply arrives asynchronously and does not resolve here.
7238
7053
  */
7239
- dingdongVolume: {
7240
- param: DOORBELL_CMD.DINGDONG_VOLUME,
7241
- type: "number",
7242
- unit: "%",
7243
- kind: "percent",
7244
- provenance: "verified",
7245
- min: 0,
7246
- max: 100,
7247
- description: "Doorbell chime volume, 0-100 (1717 APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME; SET_PAYLOAD envelope, distinct from the direct-binary ringtoneVolume/1708 \u2014 see DOORBELL_CMD.DINGDONG_VOLUME). Write wire-confirmed live on T8214, observed values 3 and 25.",
7248
- write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0, void 0, "auto")
7054
+ refreshState: {
7055
+ action: () => requestStateCommand(),
7056
+ description: "Ask the device to report its current state; the reply refreshes the reads."
7249
7057
  },
7250
7058
  /**
7251
- * The same `1350` SET_PAYLOAD shape and capture session as {@link DOORBELL_MEMBERS.dingdongVolume},
7252
- * but an INDEX rather than a level. Clamping a bad index would land on a real-but-wrong tone (garbage
7253
- * → 0, too-big → 9) instead of failing, and only 3 of the 10 indices (0, 7, 8) are wire-tested — so
7254
- * anything outside {@link DoorbellRingtone} is refused.
7059
+ * Gated on the MODEL, not on a value, and carrying no description: it refuses off the confirmed list
7060
+ * for a reason a generated message cannot give, and its id names an entry in a catalog that only
7061
+ * exists at runtime, so a generated control would have no domain to offer. Neither is a property
7062
+ * write, so it keeps its own signature.
7255
7063
  */
7256
- dingdongRingtone: {
7257
- param: DOORBELL_CMD.DINGDONG_RINGTONE,
7258
- type: "number",
7259
- kind: "enum",
7260
- enumValues: enumLabels(DoorbellRingtone),
7261
- provenance: "verified",
7262
- args: [{ name: "ringtone", kind: "enum", description: "A tone INDEX, not a volume." }],
7263
- description: "Doorbell chime ringtone SELECTION INDEX, not a bool (1718 APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE; SET_PAYLOAD envelope \u2014 see DOORBELL_CMD.DINGDONG_RINGTONE). Write wire-confirmed live on T8214, observed values 4 and 0. Named options: see the {@link DoorbellRingtone} enum. A write outside that enum is REJECTED, not clamped into range \u2014 this is a selection, not a volume, so a bad index would otherwise land on a real-but-wrong tone.",
7264
- write: (v, ctx) => {
7265
- const tone = coerceEnumValue(DoorbellRingtone, v);
7266
- return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0, void 0, "auto");
7064
+ setEffect: method(({ ctx, sink }) => (lightId) => CONFIRMED_EFFECT_MODELS.has(modelKey(ctx.model)) ? sink.dispatch(presetCommand(lightId)) : Promise.reject(new Error(`light-effect write is verified only on ${[...CONFIRMED_EFFECT_MODELS].join(", ")}; model ${ctx.model ?? "unknown"} uses a per-family effect-frame encoding that isn't reverse-engineered \u2014 refusing to send the captured SKU's bytes (on/off/brightness still work)`)), "Select a light-effect gallery entry by its catalog id. Colours are an RGBCW approximation, exact on primaries."),
7065
+ /**
7066
+ * Plain custom colour, distinct from the gallery-effect wire. The frame addresses every reported
7067
+ * segment, so a current positive segment count is mandatory and no family-wide length is guessed.
7068
+ * Completion acknowledges transport publication only; the device reports no authoritative RGB.
7069
+ */
7070
+ setColor: method(({ ctx, sink, read }) => (color) => {
7071
+ if (!CONFIRMED_COLOR_MODELS.has(modelKey(ctx.model))) {
7072
+ return Promise.reject(new Error(`custom-colour write is verified only on ${[...CONFIRMED_COLOR_MODELS].join(", ")}; model ${ctx.model ?? "unknown"} has no confirmed 0x0206 frame encoding`));
7267
7073
  }
7268
- },
7074
+ const segmentCount = read("lightLength")?.value;
7075
+ if (typeof segmentCount !== "number" || !Number.isInteger(segmentCount) || segmentCount < 1 || segmentCount > MAX_COLOR_SEGMENTS) {
7076
+ return Promise.reject(new Error(`custom-colour write requires a reported integer segment count in 1..${MAX_COLOR_SEGMENTS}`));
7077
+ }
7078
+ const channels = color && [color.red, color.green, color.blue];
7079
+ if (!channels || channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) {
7080
+ return Promise.reject(new Error("custom-colour RGB channels must be integers in 0..255"));
7081
+ }
7082
+ return sink.dispatch(colorCommand(color, segmentCount));
7083
+ }, "Set one plain RGB colour across all reported segments on verified T8L02 lights. Preserves configured brightness; completion acknowledges publication, not observed colour.", (ctx) => CONFIRMED_COLOR_MODELS.has(modelKey(ctx.model)))
7084
+ };
7085
+ var SMART_LIGHT = {
7086
+ capability: "smart_light",
7087
+ line: "life",
7088
+ description: "eufy_life smart-lighting: T8L02 Permanent Outdoor Lights and similar (on/off/brightness/custom colour/effect).",
7089
+ members: SMART_LIGHT_MEMBERS,
7090
+ properties: propertiesOf(SMART_LIGHT_MEMBERS),
7091
+ detection: { codecs: ["light"] },
7269
7092
  /**
7270
- * The live-view quality half of {@link DOORBELL_CMD.VIDEO_QUALITY}, lifted out of the composite by
7271
- * `decode`. `writtenElsewhere` because the setter needs the OTHER half's current value to rebuild the
7272
- * integer, which a member's `write` — given only the new value and the context — cannot read.
7093
+ * Ask for a state snapshot as soon as this device's realtime channel is up, so the typed getters are
7094
+ * populated at connect rather than only after the first write — reports are pushed on change, with
7095
+ * no periodic heartbeat to wait for.
7273
7096
  */
7274
- videoQuality: {
7275
- param: DOORBELL_CMD.VIDEO_QUALITY,
7276
- type: "number",
7277
- kind: "enum",
7278
- enumValues: enumLabels(DoorbellVideoQuality),
7279
- provenance: "verified",
7280
- writtenElsewhere: true,
7281
- decode: (raw) => {
7282
- const v = composite(raw);
7283
- return v === void 0 ? void 0 : v % 5;
7284
- },
7285
- decodedKind: "enum",
7286
- decodedValues: Object.values(DoorbellVideoQuality),
7287
- description: "Live-view quality: 0 Auto / 1 Low / 2 Medium / 3 High (1705 CMD_BAT_DOORBELL_VIDEO_QUALITY). The parameter also carries the video encoding format, so this is the remainder mod 5 \u2014 see highCompressionEncoding for the other half. Ranks, not resolutions: the app's picker reads Auto/Low/Medium/High. Written by setVideoQuality, which takes the encoding format too \u2014 hardware-confirmed on a T8210."
7097
+ realtimeInit() {
7098
+ return [requestStateCommand()];
7288
7099
  },
7100
+ decodeState(signal) {
7101
+ const params = reportFrom(signal);
7102
+ return params ? { params } : null;
7103
+ },
7104
+ emits: ["smartLightState"],
7289
7105
  /**
7290
- * The encoding half of the SAME integer, so it declares no param of its own and reaches into the
7291
- * owner's value — two members claiming 1705 would leave the second reading a name nothing is stored
7292
- * under, and a paramless member publishes no schema entry at all, which is what keeps that from
7293
- * happening structurally. High compression is the app's default on the captured device.
7106
+ * Surface a status report as one `smartLightState` event carrying every field it decoded. Fails
7107
+ * closed (returns `null`, never throws) on any shape mismatch.
7294
7108
  */
7295
- highCompressionEncoding: {
7296
- readsFrom: "videoQuality",
7297
- type: "bool",
7298
- kind: "boolean",
7299
- provenance: "verified",
7300
- decode: (raw) => {
7301
- const v = composite(raw);
7302
- return v === void 0 ? void 0 : v >= 5;
7303
- },
7304
- description: "Video encoding format: true = high compression, false = low compression (the +5 offset inside 1705, which also carries videoQuality). Set through setVideoQuality, which takes both halves because the wire carries both."
7109
+ decodeEvent(signal) {
7110
+ const params = reportFrom(signal);
7111
+ if (!params)
7112
+ return null;
7113
+ const num2 = (id) => params[id] === void 0 ? void 0 : Number(params[id]);
7114
+ const bool = (id) => params[id] === void 0 ? void 0 : params[id] === "1";
7115
+ return {
7116
+ event: "smartLightState",
7117
+ payload: {
7118
+ power: bool(LIGHT_PARAM.POWER),
7119
+ brightness: num2(LIGHT_PARAM.BRIGHTNESS),
7120
+ lightLength: num2(LIGHT_PARAM.LENGTH),
7121
+ effectId: num2(LIGHT_PARAM.EFFECT_ID),
7122
+ colorGradient: bool(LIGHT_PARAM.COLOR_GRADIENT),
7123
+ cloudEffectId: num2(LIGHT_PARAM.CLOUD_EFFECT_ID)
7124
+ }
7125
+ };
7305
7126
  },
7127
+ /** Only the no-argument power verbs, which carry no value for a member to hold. */
7128
+ actions({ sink }) {
7129
+ return {
7130
+ on: () => sink.dispatch(deviceInfoCommand({ isOn: true })),
7131
+ off: () => sink.dispatch(deviceInfoCommand({ isOn: false }))
7132
+ };
7133
+ }
7134
+ };
7135
+
7136
+ // dist/model/capabilities/ptz.js
7137
+ var PTZ_CMD = {
7138
+ /** Rotate. App `SetPtz…`/`INDOOR_ROTATE`: 1700 wrapper, `{cmd_type,rotate_type,zoom}`. */
7139
+ PTZ_ROTATE: 6030,
7140
+ /** Digital zoom. App `SetPictureZoomParser` (`COMMAND_DUAL_CAMERA_ZOOM`): 1350 SET_PAYLOAD sub-command,
7141
+ * payload `{x,y,w,h,offset,orgZoom,dstZoom}`. Also the inbound `1351` zoom-notify `cmd`. */
7142
+ PTZ_ZOOM: 6203,
7143
+ /** Go to a stored preset — AND the save-preset frame (identical bytes; the camera moves vs creates
7144
+ * by whether the slot is occupied). App `SetPresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_POINT`):
7145
+ * 1700 wrapper, `{settingstate:0, value:<presetId>}`. */
7146
+ PTZ_PRESET_GOTO: 6032,
7147
+ /** Capture the camera's current frame as a preset's thumbnail. App `COMMAND_APP_SPAN_PTZ_PIC`: 1700
7148
+ * wrapper, `{value:<presetId>}`. The app sends this right before the 6032 save when creating a preset. */
7149
+ PTZ_PRESET_PIC: 6097,
7150
+ /** Delete a stored preset. App `DeletePresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_DELETE`):
7151
+ * 1700 wrapper, `{value:<presetId>}`. */
7152
+ PTZ_PRESET_DELETE: 6033,
7153
+ /** List stored presets. App `QueryPresetPositionsParser` (`COMMAND_INDOOR_SPAN_CRUISE_QUERY`): 1700
7154
+ * request whose reply is a `1351` notify carrying `{points:[…]}`. */
7155
+ PTZ_PRESET_QUERY: 6034,
7156
+ /** Preview a stored preset — move to it transiently (the app uses this to show a preset before
7157
+ * committing a default). App `COMMAND_INDOOR_SPAN_CRUISE_PREVIEW`: 1700 wrapper, `{value:<presetId>}`. */
7158
+ PTZ_PRESET_PREVIEW: 6035,
7159
+ /** Mark a stored preset as the default/home position. App `DefaultPresetPositionsParser`
7160
+ * (`COMMAND_APP_SET_DEFAULT_POSITION`): 1350 SET_PAYLOAD, payload `{index:<presetId>, settingstate}`.
7161
+ * A compound builder — the app follows it with PREVIEW (6035) + PTZ_PIC (6097) to refresh the
7162
+ * thumbnail; the default-set itself is this frame. */
7163
+ PTZ_SET_DEFAULT_POSITION: 6242,
7164
+ /** How fast a rotate STEP travels. 1700 wrapper, `{value:1|3|5}`. Observed live: the app's
7165
+ * Slow/Mid/Fast control on an indoor pan-tilt writes 1 / 3 / 5 and nothing else moves. The id sits
7166
+ * inside the smart lock's `601x` block, so the shared param dictionary labels it `lockParam` — an id
7167
+ * reused across product lines, and the camera's meaning is the observed one. */
7168
+ PTZ_ROTATE_SPEED: 6015
7169
+ };
7170
+ var PTZ_FRAME = {
7171
+ /** SoloCam JSON status wrapper — `{cmd, payload}`. */
7172
+ NOTIFY_PAYLOAD: 1351,
7173
+ /** Indoor-PT binary position stream wrapper. */
7174
+ CONTROL_PAYLOAD: 1700
7175
+ };
7176
+ var PtzDirection = {
7177
+ /** Pan left (one step). */
7178
+ left: "left",
7179
+ /** Pan right (one step). */
7180
+ right: "right",
7181
+ /** Tilt up (one step). */
7182
+ up: "up",
7183
+ /** Tilt down (one step). */
7184
+ down: "down"
7185
+ };
7186
+ var PTZ_ROTATE = { left: 1, right: 2, up: 3, down: 4 };
7187
+ var ZOOM_EVIDENCE_PARAMS = [6204, 6205];
7188
+ var ZOOM_MODEL_HINT = /^T8170\b|^T8425\b|S340/i;
7189
+ function hasZoomEvidence(ctx) {
7190
+ return ZOOM_EVIDENCE_PARAMS.some((p) => ctx.paramIds.has(p)) || !!ctx.model && ZOOM_MODEL_HINT.test(ctx.model);
7191
+ }
7192
+ var PRESET_EVIDENCE_PARAMS = [1029, 6090, 6091, 6092, 6210];
7193
+ var FIXED_LENS_MODELS = /* @__PURE__ */ new Set(["T8134"]);
7194
+ function rotateCommand(direction, ctx, zoom = 1) {
7195
+ return setJson(PTZ_CMD.PTZ_ROTATE, { cmd_type: 1, rotate_type: PTZ_ROTATE[direction], zoom }, ctx);
7196
+ }
7197
+ function zoomCommand(dstZoom, ctx, region) {
7198
+ const r = region ?? {};
7199
+ const offset = r.offset ?? false;
7200
+ return setPayload(PTZ_CMD.PTZ_ZOOM, {
7201
+ x: offset ? r.x ?? 0 : 0,
7202
+ y: offset ? r.y ?? 0 : 0,
7203
+ w: offset ? r.w ?? 0 : 0,
7204
+ h: offset ? r.h ?? 0 : 0,
7205
+ offset,
7206
+ orgZoom: r.orgZoom ?? 0,
7207
+ dstZoom
7208
+ }, ctx, 0, void 0, "auto");
7209
+ }
7210
+ function gotoPresetCommand(id, ctx) {
7211
+ return setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx);
7212
+ }
7213
+ function deletePresetCommand(id, ctx) {
7214
+ return setJson(PTZ_CMD.PTZ_PRESET_DELETE, { value: id }, ctx);
7215
+ }
7216
+ function previewPresetCommand(id, ctx) {
7217
+ return setJson(PTZ_CMD.PTZ_PRESET_PREVIEW, { value: id }, ctx);
7218
+ }
7219
+ function presetPicCommand(id, ctx) {
7220
+ return setJson(PTZ_CMD.PTZ_PRESET_PIC, { value: id }, ctx);
7221
+ }
7222
+ function savePresetCommand(id, ctx) {
7223
+ return [presetPicCommand(id, ctx), setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx)];
7224
+ }
7225
+ function setDefaultPositionCommand(presetId, ctx) {
7226
+ return setPayload(PTZ_CMD.PTZ_SET_DEFAULT_POSITION, { index: presetId, settingstate: 0 }, ctx);
7227
+ }
7228
+ function parsePresetPoints(reply) {
7229
+ const points = reply?.points;
7230
+ if (!Array.isArray(points))
7231
+ return [];
7232
+ const out = [];
7233
+ for (const p of points) {
7234
+ if (typeof p !== "object" || p === null)
7235
+ continue;
7236
+ const rec = p;
7237
+ const rawId = rec.index ?? rec.id ?? rec.value;
7238
+ const id = typeof rawId === "number" ? rawId : Number(rawId);
7239
+ if (!Number.isFinite(id))
7240
+ continue;
7241
+ out.push({ id, raw: rec });
7242
+ }
7243
+ return out;
7244
+ }
7245
+ function parsePresetImage(reply) {
7246
+ const data = reply?.data;
7247
+ if (typeof data !== "string" || data.length === 0)
7248
+ return void 0;
7249
+ const rawIndex = reply?.index;
7250
+ const index = typeof rawIndex === "number" ? rawIndex : Number.isFinite(Number(rawIndex)) ? Number(rawIndex) : -1;
7251
+ return { index, data };
7252
+ }
7253
+ function isDirection(v) {
7254
+ return v === "left" || v === "right" || v === "up" || v === "down";
7255
+ }
7256
+ var PTZ_MEMBERS = {
7306
7257
  /**
7307
- * Set both halves of {@link DOORBELL_CMD.VIDEO_QUALITY} at once, because the wire has no way to set
7308
- * one alone: every frame carries the whole integer.
7258
+ * How fast a rotate STEP travels, on the app's own three-position control: `1` slow, `3` mid, `5`
7259
+ * fast. A `scalar` rather than an enum — the endpoints and the midpoint are observed, so 2 and 4 are
7260
+ * a prediction of the scale's shape and the wire is not known to reject them.
7309
7261
  *
7310
- * Taking both is not a convenience choice. An earlier shape took one and preserved the other by
7311
- * reading it back, and it failed on hardware: the state reader is the snapshot the device was bound
7312
- * with, so a second write in the same session composed against the value from before the first and
7313
- * silently reverted it. Requiring both puts that decision where it can be made correctly — a caller
7314
- * reads {@link DOORBELL_MEMBERS.videoQuality} and {@link DOORBELL_MEMBERS.highCompressionEncoding}
7315
- * and passes what it wants, instead of the SDK guessing from a stale copy.
7316
- */
7317
- setVideoQuality: method(({ ctx, sink }) => async (quality, highCompression) => {
7318
- const q = coerceEnumValue(DoorbellVideoQuality, quality);
7319
- if (q === void 0) {
7320
- throw new Error(`videoQuality: ${String(quality)} is not one of ${enumLabels(DoorbellVideoQuality)}`);
7321
- }
7322
- await sink.dispatch(setScalar(DOORBELL_CMD.VIDEO_QUALITY, q + (asBool(highCompression) ? 5 : 0), ctx, "direct-binary"));
7323
- }, "Set the live-view quality and the video encoding format, which share one wire."),
7324
- /**
7325
- * A config blob the device reports WHOLE, so it is typed `string` and handed back unparsed — three
7326
- * settings live inside it (motion notifications, ring notifications, notification style) and the SDK
7327
- * lifts none of them out, because no `decode` is evidenced for the shape. Read-only: no write frame
7328
- * has been captured for it, and a config JSON is exactly where a guessed write does the most damage.
7262
+ * Not the same thing as `rotate`'s `zoom` argument, which scales one step's SIZE rather than its
7263
+ * speed.
7264
+ *
7265
+ * The parameter is **absent until first written**: a camera whose speed has never been set reports
7266
+ * no `6015` at all, so the getter is installed only once the value exists — an absent reading is "not
7267
+ * configured", never "unsupported".
7329
7268
  */
7330
- notificationMode: {
7331
- param: 1710,
7332
- type: "string",
7333
- kind: "text",
7269
+ rotationSpeed: {
7270
+ param: PTZ_CMD.PTZ_ROTATE_SPEED,
7271
+ type: "number",
7272
+ kind: "scalar",
7334
7273
  provenance: "verified",
7335
- description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
7274
+ description: "Pan/tilt rotate speed: 1 = slow, 3 = mid, 5 = fast (PTZ_ROTATE_SPEED 6015). \u2705 Write confirmed live on a T8410 (mains, standalone): the app's Slow/Mid/Fast control identified 1/3/5, then 1 and 5 were sent consecutively through this member and read back on the cloud param. 2 and 4 are the scale's shape, not observed values. Absent until first written.",
7275
+ write: (v, ctx) => {
7276
+ const n = Number(v);
7277
+ if (!Number.isInteger(n) || n < 1 || n > 5)
7278
+ return void 0;
7279
+ return setJson(PTZ_CMD.PTZ_ROTATE_SPEED, { value: n }, ctx);
7280
+ }
7336
7281
  },
7282
+ /** Rotate a step in a direction; `zoom` scales the step size (1.0 = the default step). */
7283
+ rotate: method(({ ctx, sink }) => (direction, zoom = 1) => sink.dispatch(rotateCommand(direction, ctx, zoom)), "Rotate a step in a direction."),
7284
+ /** Rotate one step left. */
7285
+ left: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("left", ctx)), "Rotate left."),
7286
+ /** Rotate one step right. */
7287
+ right: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("right", ctx)), "Rotate right."),
7288
+ /** Rotate one step up. */
7289
+ up: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("up", ctx)), "Rotate up."),
7290
+ /** Rotate one step down. */
7291
+ down: method(({ ctx, sink }) => () => sink.dispatch(rotateCommand("down", ctx)), "Rotate down."),
7337
7292
  /**
7338
- * Play one of the doorbell's quick responses at the visitor. `voiceId` comes from
7339
- * {@link DOORBELL_MEMBERS.quickResponses}. The doorbell only plays while it has an active media
7340
- * session, so by default this briefly engages a live stream, sends, then tears it down.
7341
- * `{ engage: false }` leaves an already-open stream alone.
7342
- *
7343
- * If engagement was requested but no stream comes up this THROWS, rather than firing 1706 into the
7344
- * void and reporting success — the doorbell would play nothing. The wire is `QUICK_RESPONSE` 1706,
7345
- * the SAME direct-binary command as camera on/off, carrying the voice id as its value.
7293
+ * Zoom rides a SECOND (telephoto) lens, so it is offered only where the device shows zoom evidence —
7294
+ * otherwise `dev.ptz()?.zoom` would be a silent no-op on a single-lens pan-tilt camera.
7346
7295
  */
7347
- playQuickResponse: method(({ ctx, sink, media }) => async (voiceId, opts = {}) => {
7348
- const engage = opts.engage ?? true;
7349
- let stream;
7350
- if (engage) {
7351
- if (!media) {
7352
- throw new Error("playQuickResponse needs a live media session to engage, but the device is not bound to a live client \u2014 open a stream and call with { engage: false }");
7353
- }
7354
- try {
7355
- stream = await media.live();
7356
- } catch (e) {
7357
- throw new Error(`playQuickResponse could not engage a live stream (the doorbell only plays while live): ${e instanceof Error ? e.message : String(e)}`);
7358
- }
7359
- await sleep(2500);
7360
- }
7361
- try {
7362
- await sink.dispatch(setScalar(DOORBELL_CMD.QUICK_RESPONSE, voiceId, ctx, "direct-binary"));
7363
- if (stream)
7364
- await sleep(1500);
7365
- } finally {
7366
- try {
7367
- stream?.stop();
7368
- } catch {
7369
- }
7370
- }
7371
- }, "Play a quick response at the visitor."),
7296
+ zoom: method(({ ctx, sink }) => (dstZoom, region) => sink.dispatch(zoomCommand(dstZoom, ctx, region)), "Zoom the telephoto lens.", hasZoomEvidence),
7372
7297
  /**
7373
- * Fetch the doorbell's quick-response list — a P2P request/reply query that returns data, so it
7374
- * exists only on a device bound to a media provider that offers one.
7298
+ * The preset sub-API. Write verbs dispatch through the sink; the READ verbs (`list`/`image`) are P2P
7299
+ * request/reply and exist only when bound to a media provider — the transport stays
7300
+ * capability-agnostic (it runs a generic control-payload query and resolves the correlated notify)
7301
+ * while this module owns the sub-command id and the reply parsing.
7375
7302
  *
7376
- * The transport stays doorbell-agnostic: it runs a GENERIC SET_PAYLOAD query (sub-cmd → reply
7377
- * payload); this capability owns the sub-command id (6237) and the reply parsing.
7303
+ * `answers` because calling it performs nothing: it hands back the namespace the verbs live on. Offering
7304
+ * it as a control would produce one that returns an object and does nothing.
7378
7305
  */
7379
- quickResponses: provided("media", (m) => m.p2pQuery && (async (opts) => {
7380
- const payload = await m.p2pQuery(DOORBELL_CMD.GET_QUICK_RESPONSE_LIST, opts);
7381
- const voiceList = Array.isArray(payload?.voice_list) ? payload.voice_list : [];
7382
- return parseQuickResponses(voiceList);
7383
- }), "Fetch the doorbell's quick-response list.")
7306
+ preset: {
7307
+ ...method(({ ctx, sink, media }) => () => {
7308
+ const p = {
7309
+ goto: (id) => sink.dispatch(gotoPresetCommand(id, ctx)),
7310
+ preview: (id) => sink.dispatch(previewPresetCommand(id, ctx)),
7311
+ save: async (id) => {
7312
+ for (const cmd of savePresetCommand(id, ctx))
7313
+ await sink.dispatch(cmd);
7314
+ },
7315
+ setDefault: (id) => sink.dispatch(setDefaultPositionCommand(id, ctx)),
7316
+ delete: (id) => sink.dispatch(deletePresetCommand(id, ctx))
7317
+ };
7318
+ if (media?.p2pControlQuery) {
7319
+ p.list = async (opts) => parsePresetPoints(await media.p2pControlQuery(PTZ_CMD.PTZ_PRESET_QUERY, { value: 0 }, opts));
7320
+ p.image = async (id, opts) => parsePresetImage(await media.p2pControlQuery(PTZ_CMD.PTZ_PRESET_PIC, { value: id }, opts));
7321
+ }
7322
+ return p;
7323
+ }, "The preset sub-API: goto/preview/save/setDefault/delete, plus list/image when bound."),
7324
+ answers: true
7325
+ }
7384
7326
  };
7385
- var DOORBELL = {
7386
- capability: "doorbell",
7387
- description: "Doorbell chime / ringtone configuration (ring press is an event, not a param).",
7388
- members: DOORBELL_MEMBERS,
7389
- properties: propertiesOf(DOORBELL_MEMBERS),
7327
+ var PTZ = {
7328
+ capability: "ptz",
7329
+ description: "Pan/tilt (PTZ) control. Movement is command-driven and rotate speed is stored; position arrives as a live event.",
7390
7330
  /**
7391
- * Doorbells self-report no param of their own (the 1702-1719 settings also arrive from eufyCams), and
7392
- * the cloud record names no product, so detection keys off the app's own tables:
7393
- * {@link DOORBELL_MODELS} and {@link DOORBELL_TYPES}.
7331
+ * The intent route for the movement verbs — kept alongside the member table because these are
7332
+ * ARGUMENT-TAKING verbs, not property writes: `rotate` takes a direction, the preset verbs take an id.
7333
+ * A member's `write` moves one named value, so it cannot name them, and dropping this would silently
7334
+ * remove `setProperty("gotoPreset", 2)` from the low-level path. Each case delegates to the same
7335
+ * builder the fluent method uses, so the two cannot drift. Zoom carries the same dual-lens gate the
7336
+ * fluent `zoom` member does.
7394
7337
  */
7338
+ buildCommand(action, value, ctx) {
7339
+ if (action === "rotate" && isDirection(value))
7340
+ return rotateCommand(value, ctx);
7341
+ if (typeof value === "number") {
7342
+ if (action === "zoom")
7343
+ return hasZoomEvidence(ctx) ? zoomCommand(value, ctx) : void 0;
7344
+ if (action === "gotoPreset")
7345
+ return gotoPresetCommand(value, ctx);
7346
+ if (action === "previewPreset")
7347
+ return previewPresetCommand(value, ctx);
7348
+ if (action === "deletePreset")
7349
+ return deletePresetCommand(value, ctx);
7350
+ if (action === "setDefaultPosition")
7351
+ return setDefaultPositionCommand(value, ctx);
7352
+ }
7353
+ return void 0;
7354
+ },
7355
+ members: PTZ_MEMBERS,
7356
+ properties: propertiesOf(PTZ_MEMBERS),
7357
+ // SoloCam proves PT via reported preset params (6090/6091/6092/6210) and a rotate command surface
7358
+ // (1029), except on a FIXED_LENS_MODELS model; Indoor-PT (deviceType 31/35/111 + C220 range
7359
+ // 10008-10011) reports NO PT param, so the vendor deviceType is the only honest signal; explicit
7360
+ // PT/S340/S350 model names also prove it.
7361
+ // OUTDOOR_PT_CAMERA (48, e.g. SoloCam S340 / T8170) is pan-tilt by definition but a live unit can
7362
+ // report none of the preset params — trust the deviceType. The rotating SoloCam solar variant
7363
+ // T8124R shares deviceType 62 with the FIXED T8124 base, so its model code is the only honest
7364
+ // signal that separates the two. Match ONLY confirmed rotating model codes here — do NOT widen to
7365
+ // a `T81\d\dR` wildcard: the "R = rotating" reading is unverified for other models, and an
7366
+ // over-broad hint would false-positive a future non-PT model, the same class of bug as a mislabel.
7395
7367
  detection: {
7396
- detect: (rec) => DOORBELL_MODELS.has(rec.model ?? ""),
7397
- deviceTypes: DOORBELL_TYPES,
7398
- modelHints: [/doorbell/i]
7368
+ detect: (record) => !FIXED_LENS_MODELS.has(record.model ?? "") && PRESET_EVIDENCE_PARAMS.some((p) => Object.hasOwn(record.params ?? {}, p)),
7369
+ deviceTypes: [
7370
+ DeviceType.OUTDOOR_PT_CAMERA,
7371
+ DeviceType.INDOOR_PT_CAMERA,
7372
+ DeviceType.INDOOR_PT_CAMERA_1080,
7373
+ DeviceType.CAMERA_4G_S330,
7374
+ DeviceType.INDOOR_PT_CAMERA_C220,
7375
+ DeviceType.INDOOR_PT_CAMERA_C210,
7376
+ DeviceType.INDOOR_PT_CAMERA_C220_V2,
7377
+ DeviceType.INDOOR_PT_CAMERA_C220_V3,
7378
+ // T8425 has its own dedicated deviceType (47), distinct from OUTDOOR_PT_CAMERA (48) — easy to
7379
+ // miss since the name doesn't say "PT". Was omitted here despite T8425 being the hardware that
7380
+ // grounded the rotate_type wire mapping (see PTZ_ROTATE's doc comment) — confirmed live it has
7381
+ // no PTZ capability without this, on a real owned unit.
7382
+ DeviceType.FLOODLIGHT_CAMERA_8425
7383
+ ],
7384
+ modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i, /\bT8E00\b/i]
7399
7385
  },
7386
+ emits: ["ptzNotify"],
7400
7387
  /**
7401
- * Inbound FCM doorbell events (`DoorbellPushEvent`): ring press and the package trio.
7402
- *
7403
- * Pet (3106) is NOT here. The id is declared identically in the doorbell, indoor and HB3-paired
7404
- * vocabularies, so it belongs to the camera-wide `motion` module that every camera binds; claiming
7405
- * it here as well would make it a contested id that a doorbell — which has both capabilities —
7406
- * matches twice, emitting one push as two events.
7388
+ * Surface PTZ status the camera streams back while it moves. Two wire shapes (both prove pan-tilt):
7389
+ * - SoloCam: `CMD_NOTIFY_PAYLOAD` (1351) JSON `{cmd, payload}` with cmd 6030 (rotate) / 6203 (zoom).
7390
+ * - Indoor PT: `CMD_DOORBELL_SET_PAYLOAD` (1700) binary 24-byte records of float `(pan, tilt)`
7391
+ * (non-zero while moving; a fixed camera sends zeros).
7392
+ * `stationSn` is added by the barrel, so it is NOT included in the returned payload.
7407
7393
  */
7408
- events: [
7409
- { source: "push", match: DoorbellPushEvent.PRESS_DOORBELL, emit: "doorbellPress" },
7410
- { source: "push", match: DoorbellPushEvent.PACKAGE_DELIVERED, emit: "packageDelivered" },
7411
- { source: "push", match: DoorbellPushEvent.PACKAGE_TAKEN, emit: "packageTaken" },
7412
- { source: "push", match: DoorbellPushEvent.PACKAGE_STRANDED, emit: "packageStranded" }
7413
- ]
7394
+ decodeEvent(signal) {
7395
+ if (signal.source !== "p2p-frame")
7396
+ return null;
7397
+ const f = signal;
7398
+ if (f.commandId === PTZ_FRAME.NOTIFY_PAYLOAD && f.json && typeof f.json.cmd === "number" && (f.json.cmd === PTZ_CMD.PTZ_ROTATE || f.json.cmd === PTZ_CMD.PTZ_ZOOM)) {
7399
+ return {
7400
+ event: "ptzNotify",
7401
+ payload: { kind: f.json.cmd === PTZ_CMD.PTZ_ZOOM ? "zoom" : "rotate", payload: f.json.payload }
7402
+ };
7403
+ }
7404
+ if (f.commandId === PTZ_FRAME.CONTROL_PAYLOAD && f.data && f.data.length >= 28) {
7405
+ const coords = [];
7406
+ for (let o = 4; o + 24 <= f.data.length; o += 24) {
7407
+ const pan = f.data.readFloatLE(o + 12);
7408
+ const tilt = f.data.readFloatLE(o + 16);
7409
+ if (Number.isFinite(pan) && Number.isFinite(tilt) && Math.abs(pan) <= 2 && Math.abs(tilt) <= 2) {
7410
+ coords.push([pan, tilt]);
7411
+ }
7412
+ }
7413
+ if (coords.some(([p, t]) => p !== 0 || t !== 0)) {
7414
+ return { event: "ptzNotify", payload: { kind: "position", coords } };
7415
+ }
7416
+ }
7417
+ return null;
7418
+ }
7414
7419
  };
7415
7420
 
7416
7421
  // dist/model/capabilities/leak.js
@@ -16201,6 +16206,9 @@ function traceLiveStart(logger, trace, source) {
16201
16206
  logger.debug(LIVE_TRACE_MESSAGE, source === void 0 ? trace : { ...trace, source });
16202
16207
  }
16203
16208
 
16209
+ // dist/transport/p2p/level2-settle.js
16210
+ var LEVEL2_SETTLE_MS = 8e3;
16211
+
16204
16212
  // dist/transport/p2p/p2p-session.js
16205
16213
  var LOCAL_LOOKUP_PORT = 32108;
16206
16214
  var HEARTBEAT_MS = 5e3;
@@ -17777,7 +17785,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17777
17785
  });
17778
17786
  const body = Buffer.concat([
17779
17787
  buildCommandHeader(this.seqNumber, CMD_SET_PAYLOAD),
17780
- buildStringCommandPayload(value, channel, this.level1Key, 1)
17788
+ this.databasePayload(value, channel)
17781
17789
  ]);
17782
17790
  this.seqNumber = this.seqNumber + 1 & 65535;
17783
17791
  this.logger.debug(`[p2p] ${this.cfg.stationSn} requestImage ${filePath}`);
@@ -17815,12 +17823,20 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17815
17823
  });
17816
17824
  const body = Buffer.concat([
17817
17825
  buildCommandHeader(this.seqNumber, CMD_SET_PAYLOAD),
17818
- buildStringCommandPayload(value, channel, this.level1Key, 1)
17826
+ this.databasePayload(value, channel)
17819
17827
  ]);
17820
17828
  this.seqNumber = this.seqNumber + 1 & 65535;
17821
17829
  this.logger.debug(`[p2p] ${this.cfg.stationSn} queryDatabase ${table} ch=${channel}`);
17822
17830
  this.send(this.connectAddress, RequestMessageType.DATA, body);
17823
17831
  }
17832
+ /**
17833
+ * The `CMD_SET_PAYLOAD` payload of a database read or stored-image request: the JSON level-1 encrypted, or
17834
+ * in the clear with header bytes from {@link nextClearCommandBytes} on a session that {@link sendsInTheClear}
17835
+ * and holds no level-2 key.
17836
+ */
17837
+ databasePayload(value, channel) {
17838
+ return !this.level2Key && this.sendsInTheClear() ? buildRawCommandPayload(Buffer.from(value, "utf-8"), channel, 0, this.nextClearCommandBytes(), 0) : buildStringCommandPayload(value, channel, this.level1Key, 1);
17839
+ }
17824
17840
  /**
17825
17841
  * Standard "give me the whole table" query params the eufy app uses for a direct
17826
17842
  * `CMD_DATABASE` read (inner `cmd 10000`). `count` bounds the row count.
@@ -17893,10 +17909,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17893
17909
  *
17894
17910
  * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
17895
17911
  * session throws, so a second reply cannot land in this buffer.
17912
+ *
17913
+ * The query's framing follows the cipher the gateway-info reply names, so on a session still negotiating
17914
+ * its level-2 key the query waits for that to settle, up to {@link LEVEL2_SETTLE_MS} from connect.
17896
17915
  */
17897
17916
  async readDatabase(table, opts = {}) {
17898
17917
  if (opts.signal?.aborted)
17899
17918
  throw new Error("readDatabase: aborted");
17919
+ if (this.level2Pending)
17920
+ await abortable(this.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
17900
17921
  this.queryDatabase(table, {
17901
17922
  accountId: opts.accountId,
17902
17923
  innerCmd: opts.innerCmd,
@@ -18199,6 +18220,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18199
18220
  } catch {
18200
18221
  }
18201
18222
  }
18223
+ } else if (header.commandId === CMD_DATABASE && this.sendsInTheClear()) {
18224
+ this.emit("dbChunk", { stationSn: this.cfg.stationSn, text: payload.toString("latin1") });
18202
18225
  }
18203
18226
  const frame = {
18204
18227
  ...header,
@@ -20550,14 +20573,20 @@ var SharedLiveSource = class {
20550
20573
  }
20551
20574
  /**
20552
20575
  * Handle a consumer leaving. When the last one detaches (1→0), release the station-session user via
20553
- * {@link SharedLiveSourceOptions.onIdle} (its own longer idle timer then arms) and arm the stream's
20554
- * linger teardown.
20576
+ * {@link SharedLiveSourceOptions.onIdle} (its own longer idle timer then arms) and, while a stream is
20577
+ * still running, arm its linger teardown.
20578
+ *
20579
+ * A source that ended its own stream ends its consumers before tearing down, and a Readable consumer
20580
+ * detaches only once it has reached EOF — after that teardown. The source is then already `stopped`,
20581
+ * with its session claim released, and stays so: lingering would make the owner hand it out again
20582
+ * instead of building a source that holds a session.
20555
20583
  */
20556
20584
  onDetach(consumer) {
20557
20585
  this.consumers.delete(consumer);
20558
20586
  if (this.consumers.size === 0 && !this.disposed) {
20559
20587
  this.opts.onIdle?.();
20560
- this.arm();
20588
+ if (this.stream)
20589
+ this.arm();
20561
20590
  }
20562
20591
  }
20563
20592
  /** Refcount hit zero — arm the linger teardown. A new attach in the window cancels it. */
@@ -22030,7 +22059,6 @@ function stationChannels(devices) {
22030
22059
  // dist/transport/p2p/command-router.js
22031
22060
  var DIRECT_CMD_SENDS = 5;
22032
22061
  var LEVEL2_GRACE_MS = 25e3;
22033
- var LEVEL2_SETTLE_MS = 8e3;
22034
22062
  var KEEP_LEVEL1_SESSION_MODELS = /* @__PURE__ */ new Set(["T8423"]);
22035
22063
  var P2P_STATION_WAITS = {
22036
22064
  connect: CONNECT_TIMEOUT_MS,
@@ -30089,7 +30117,8 @@ var EufyMega = class extends EventEmitter14 {
30089
30117
  }
30090
30118
  /**
30091
30119
  * The RTC media provider for a station on a media-track route, or a camera attached to a T9000 whose
30092
- * `device_channel` resolves back to it on the station; `undefined` otherwise, unless an NVR camera has its own dedicated media call.
30120
+ * `device_channel` resolves back to it on the station; `undefined` otherwise. A camera attached to a T8N00 gets the
30121
+ * NVR's dedicated media call when {@link isNvrMediaCamera} admits it.
30093
30122
  */
30094
30123
  rtcMediaFor(sn) {
30095
30124
  const route = this.rtcRouteFor(sn);
@@ -30204,6 +30233,8 @@ var EufyMega = class extends EventEmitter14 {
30204
30233
  * first announces it and the others stay silent. Its timing says only when a caller happened to read; the
30205
30234
  * value is the news. It applies what the device volunteered over realtime on top of the cloud half, which
30206
30235
  * the registry keeps apart, so it can neither revert nor announce a revert of a report already landed.
30236
+ * Nothing awaits that refresh, so its failure is reported like the other background paths' — a dead
30237
+ * token as `sessionExpired`, anything else as `error` — and the read keeps its last-known value.
30207
30238
  *
30208
30239
  * The `Device` returned is held WEAKLY: it is what the inbound paths announce against, so a caller that
30209
30240
  * wants property changes for a serial keeps its own reference. Dropping it stops the announcements, not
@@ -30230,8 +30261,12 @@ var EufyMega = class extends EventEmitter14 {
30230
30261
  dev.setFreshnessPolicy({
30231
30262
  staleAfterMs: this.opts.cacheTtlMs ?? 15e3,
30232
30263
  refresh: async () => {
30233
- const fresh = await this.registry.record(sn);
30234
- this.applyAndAnnounce(dev, { ...fresh.params, ...fresh.dpParams });
30264
+ try {
30265
+ const fresh = await this.registry.record(sn);
30266
+ this.applyAndAnnounce(dev, { ...fresh.params, ...fresh.dpParams });
30267
+ } catch (e) {
30268
+ this.reportError(e);
30269
+ }
30235
30270
  }
30236
30271
  });
30237
30272
  }