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