@mega-yfue/eufy-sdk 0.6.0-beta.8 → 0.6.0-beta.9

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