@mega-yfue/eufy-sdk 0.6.0-beta.0 → 0.6.0-beta.10

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