@homebridge-plugins/homebridge-eufy-security 4.7.2 → 5.0.0-beta.0
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/AUTHORS +4 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +77 -0
- package/README.md +152 -49
- package/SECURITY.md +30 -0
- package/config.schema.json +92 -3
- package/dist/account/ownership.js +290 -0
- package/dist/account/ownership.js.map +1 -0
- package/dist/account/persistence.js +274 -0
- package/dist/account/persistence.js.map +1 -0
- package/dist/account/temporary-authentication.js +348 -0
- package/dist/account/temporary-authentication.js.map +1 -0
- package/dist/configuration.js +167 -0
- package/dist/configuration.js.map +1 -0
- package/dist/device/member-evidence.js +66 -0
- package/dist/device/member-evidence.js.map +1 -0
- package/dist/device/member-trust.js +114 -0
- package/dist/device/member-trust.js.map +1 -0
- package/dist/device/snapshot.js +190 -0
- package/dist/device/snapshot.js.map +1 -0
- package/dist/diagnostics.js +2218 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/homekit/adapter.js +2 -0
- package/dist/homekit/adapter.js.map +1 -0
- package/dist/homekit/adapters/battery.js +208 -0
- package/dist/homekit/adapters/battery.js.map +1 -0
- package/dist/homekit/adapters/camera-controls.js +527 -0
- package/dist/homekit/adapters/camera-controls.js.map +1 -0
- package/dist/homekit/adapters/camera-streaming.js +1515 -0
- package/dist/homekit/adapters/camera-streaming.js.map +1 -0
- package/dist/homekit/adapters/contact.js +138 -0
- package/dist/homekit/adapters/contact.js.map +1 -0
- package/dist/homekit/adapters/doorbell.js +57 -0
- package/dist/homekit/adapters/doorbell.js.map +1 -0
- package/dist/homekit/adapters/information.js +55 -0
- package/dist/homekit/adapters/information.js.map +1 -0
- package/dist/homekit/adapters/lock.js +489 -0
- package/dist/homekit/adapters/lock.js.map +1 -0
- package/dist/homekit/adapters/motion.js +100 -0
- package/dist/homekit/adapters/motion.js.map +1 -0
- package/dist/homekit/adapters/registry.js +26 -0
- package/dist/homekit/adapters/registry.js.map +1 -0
- package/dist/homekit/adapters/security-system.js +425 -0
- package/dist/homekit/adapters/security-system.js.map +1 -0
- package/dist/homekit/adapters/siren.js +136 -0
- package/dist/homekit/adapters/siren.js.map +1 -0
- package/dist/homekit/adapters/smart-light.js +662 -0
- package/dist/homekit/adapters/smart-light.js.map +1 -0
- package/dist/homekit/coverage-matrix.js +119 -0
- package/dist/homekit/coverage-matrix.js.map +1 -0
- package/dist/homekit/device-control.js +175 -0
- package/dist/homekit/device-control.js.map +1 -0
- package/dist/homekit/reconciler.js +319 -0
- package/dist/homekit/reconciler.js.map +1 -0
- package/dist/homekit/representation.js +23 -0
- package/dist/homekit/representation.js.map +1 -0
- package/dist/index.js +4 -9
- package/dist/index.js.map +1 -1
- package/dist/media/contracts.js +2 -0
- package/dist/media/contracts.js.map +1 -0
- package/dist/media/last-successful-image.js +208 -0
- package/dist/media/last-successful-image.js.map +1 -0
- package/dist/media/live-stream.js +1003 -0
- package/dist/media/live-stream.js.map +1 -0
- package/dist/media/recording.js +560 -0
- package/dist/media/recording.js.map +1 -0
- package/dist/media/session-budget.js +39 -0
- package/dist/media/session-budget.js.map +1 -0
- package/dist/media/snapshot.js +406 -0
- package/dist/media/snapshot.js.map +1 -0
- package/dist/media/station-live-sessions.js +101 -0
- package/dist/media/station-live-sessions.js.map +1 -0
- package/dist/platform.js +97 -809
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +375 -0
- package/dist/runtime/channel.js.map +1 -0
- package/dist/runtime/owner.js +682 -0
- package/dist/runtime/owner.js.map +1 -0
- package/dist/runtime/sdk-client.js +211 -0
- package/dist/runtime/sdk-client.js.map +1 -0
- package/dist/runtime/tracker.js +211 -0
- package/dist/runtime/tracker.js.map +1 -0
- package/dist/settings.js +1 -43
- package/dist/settings.js.map +1 -1
- package/dist/storage.js +59 -0
- package/dist/storage.js.map +1 -0
- package/dist/ui/dashboard.js +181 -0
- package/dist/ui/dashboard.js.map +1 -0
- package/dist/ui/device-image.js +22 -0
- package/dist/ui/device-image.js.map +1 -0
- package/dist/ui/runtime-channel-client.js +305 -0
- package/dist/ui/runtime-channel-client.js.map +1 -0
- package/dist/ui/server.js +259 -0
- package/dist/ui/server.js.map +1 -0
- package/homebridge-ui/public/app.css +1902 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T1250.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2070.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2080.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2081.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2103.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2117.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2118.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2119.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T211A.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2120.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2123.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2128.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2130.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2132.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2150.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2181.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2182.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2190.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2192.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2193.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2194.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2210.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2250.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2251.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2252.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2253.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2254.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2255.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2256.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2257.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2258.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2259.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2261.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2262.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2266.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2267.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2268.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2270.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2272.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2273.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2276.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2277.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2278.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2280.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2292.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2320.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2351.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2352.webp +0 -0
- package/homebridge-ui/public/assets/devices/clean/clean-T2353.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L00.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L01.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L02.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L04.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L10.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L20.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L30.webp +0 -0
- package/homebridge-ui/public/assets/devices/life/life-T8L40.webp +0 -0
- package/homebridge-ui/public/assets/devices/mower/mower-T2801.webp +0 -0
- package/homebridge-ui/public/assets/devices/mower/mower-T280B.webp +0 -0
- package/homebridge-ui/public/assets/devices/mower/mower-T2880.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T7400.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T7401.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8001.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8002.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8010.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8021.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8023.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8025.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8030.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8110.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8111.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8112.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8113.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8114.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8122.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8123.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8124.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8130.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8131.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8134.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8140.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8141.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8150.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8151.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8152.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8153.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8160.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8161.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8162.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8170.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8171.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8172.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8173.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8200.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8200X.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8201.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8203.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8210.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8213.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8214.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8222.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8400.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8401.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8410.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8411.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8414.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8416.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8417.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8419.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8419N.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8420.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8420X.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8422.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8423.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8424.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8425.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8441.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8442.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8452.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T84A0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T84A1.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8500.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8502.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8503.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8504.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8506.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8510.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8510P.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8520.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8520P.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8530.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8531.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T85D0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T85L0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T85P0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T85V0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8600.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T86P2.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8790.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T87B0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T87B2.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8900.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8910.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8920.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8960.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8B0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8E00.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8N00.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T8W11C.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T90F0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T90M0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T90R0.webp +0 -0
- package/homebridge-ui/public/assets/devices/security/security-T90S0.webp +0 -0
- package/homebridge-ui/public/assets/icons/inventory.svg +1 -1
- package/homebridge-ui/public/assets/icons/troubleshoot.svg +1 -0
- package/homebridge-ui/public/assets/icons/warning.svg +1 -1
- package/homebridge-ui/public/assets/logo-dark.svg +10 -0
- package/homebridge-ui/public/assets/logo.svg +10 -0
- package/homebridge-ui/public/i18n/en.json +218 -0
- package/homebridge-ui/public/i18n/fr.json +218 -0
- package/homebridge-ui/public/index.html +377 -28
- package/homebridge-ui/public/js/app.js +1065 -0
- package/homebridge-ui/public/js/dashboard.js +385 -0
- package/homebridge-ui/public/js/legacy-settings.js +37 -0
- package/homebridge-ui/public/js/profile-wizard.js +68 -0
- package/homebridge-ui/server.js +2 -1393
- package/i18n/runtime/en.json +100 -0
- package/media/Snapshot-Unavailable.jpg +0 -0
- package/media/Snapshot-black.jpg +0 -0
- package/media/camera-disabled.jpg +0 -0
- package/media/camera-offline.jpg +0 -0
- package/package.json +29 -24
- package/.claude/CLAUDE.md +0 -175
- package/.claude/PRD.md +0 -241
- package/.claude/docs/debug-recording-lessons.md +0 -91
- package/.claude/docs/hksv-recording-fix.md +0 -160
- package/.claude/skills/architect/SKILL.md +0 -76
- package/.claude/skills/developer/SKILL.md +0 -59
- package/.claude/skills/new-device-support/SKILL.md +0 -212
- package/.claude/skills/new-device-support/check-device.mjs +0 -363
- package/.claude/skills/new-device-support/map-properties.mjs +0 -450
- package/.claude/skills/new-device-support/verify-device.mjs +0 -272
- package/.claude/skills/planner/SKILL.md +0 -100
- package/.claude/skills/qa/SKILL.md +0 -79
- package/.claude/skills/support/SKILL.md +0 -175
- package/.prettierignore +0 -7
- package/.prettierrc.json +0 -7
- package/FUNDING.yml +0 -1
- package/dist/accessories/AutoSyncStationAccessory.js +0 -157
- package/dist/accessories/AutoSyncStationAccessory.js.map +0 -1
- package/dist/accessories/BaseAccessory.js +0 -261
- package/dist/accessories/BaseAccessory.js.map +0 -1
- package/dist/accessories/CameraAccessory.js +0 -459
- package/dist/accessories/CameraAccessory.js.map +0 -1
- package/dist/accessories/Device.js +0 -65
- package/dist/accessories/Device.js.map +0 -1
- package/dist/accessories/EntrySensorAccessory.js +0 -48
- package/dist/accessories/EntrySensorAccessory.js.map +0 -1
- package/dist/accessories/GarageDoorAccessory.js +0 -160
- package/dist/accessories/GarageDoorAccessory.js.map +0 -1
- package/dist/accessories/LockAccessory.js +0 -142
- package/dist/accessories/LockAccessory.js.map +0 -1
- package/dist/accessories/MotionSensorAccessory.js +0 -48
- package/dist/accessories/MotionSensorAccessory.js.map +0 -1
- package/dist/accessories/SmartDropAccessory.js +0 -141
- package/dist/accessories/SmartDropAccessory.js.map +0 -1
- package/dist/accessories/StationAccessory.js +0 -373
- package/dist/accessories/StationAccessory.js.map +0 -1
- package/dist/config.js +0 -26
- package/dist/config.js.map +0 -1
- package/dist/controller/DebugRecordingManager.js +0 -402
- package/dist/controller/DebugRecordingManager.js.map +0 -1
- package/dist/controller/LocalLivestreamManager.js +0 -371
- package/dist/controller/LocalLivestreamManager.js.map +0 -1
- package/dist/controller/recordingDelegate.js +0 -252
- package/dist/controller/recordingDelegate.js.map +0 -1
- package/dist/controller/snapshotDelegate.js +0 -348
- package/dist/controller/snapshotDelegate.js.map +0 -1
- package/dist/controller/streamingDelegate.js +0 -389
- package/dist/controller/streamingDelegate.js.map +0 -1
- package/dist/interfaces.js +0 -2
- package/dist/interfaces.js.map +0 -1
- package/dist/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/Snapshot-black.png +0 -0
- package/dist/media/camera-disabled.png +0 -0
- package/dist/media/camera-offline.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/media/Snapshot-black.png +0 -0
- package/dist/media/media/camera-disabled.png +0 -0
- package/dist/media/media/camera-offline.png +0 -0
- package/dist/utils/Talkback.js +0 -162
- package/dist/utils/Talkback.js.map +0 -1
- package/dist/utils/accessoriesStore.js +0 -210
- package/dist/utils/accessoriesStore.js.map +0 -1
- package/dist/utils/configTypes.js +0 -38
- package/dist/utils/configTypes.js.map +0 -1
- package/dist/utils/ffmpeg.js +0 -1111
- package/dist/utils/ffmpeg.js.map +0 -1
- package/dist/utils/interfaces.js +0 -8
- package/dist/utils/interfaces.js.map +0 -1
- package/dist/utils/utils.js +0 -224
- package/dist/utils/utils.js.map +0 -1
- package/dist/version.js +0 -2
- package/dist/version.js.map +0 -1
- package/homebridge-eufy-security.png +0 -0
- package/homebridge-ui/diagnosticsCrypto.js +0 -112
- package/homebridge-ui/public/app.js +0 -237
- package/homebridge-ui/public/assets/devices/4g_lte_cam_s330_large.png +0 -0
- package/homebridge-ui/public/assets/devices/4g_lte_starlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C30.png +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C31.png +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell2k_large.png +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell2kdual_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell_e340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufy-security-client.png +0 -0
- package/homebridge-ui/public/assets/devices/eufyCamS4_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2cpro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycame330_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/flood_freeze_sensor_e20_t90F0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlightcame340_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/garage_camera_t8452_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/homebasemini_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC220_t8419N_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamc120_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcammini_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamp24_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcams350_large.png +0 -0
- package/homebridge-ui/public/assets/devices/keypad_large.png +0 -0
- package/homebridge-ui/public/assets/devices/minibase_chime_T8023_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/motionsensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/nvr_s4_max_T8N00_large.png +0 -0
- package/homebridge-ui/public/assets/devices/pir_sensor_e20_large.png +0 -0
- package/homebridge-ui/public/assets/devices/poe_bullet_ptz_cam_s4_T8E00_large.png +0 -0
- package/homebridge-ui/public/assets/devices/sensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/siren_sensor_e20_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartdrop_t8790_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8503_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8504_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8510P_t8520P_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t85D0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t85L0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t85P0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t85V0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8502_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8506_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8520_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_video_t8530_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_video_t8531_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlockwifibridge_t8021_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s10_t7400_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s12_t7401_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_card_t87B2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_link_t87B0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smoke_alarm_e10_t90S0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocamc210_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocame42_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocaml20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams220_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocams40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/soloindoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc22_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/unknown.png +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s100_large.png +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s120_large.png +0 -0
- package/homebridge-ui/public/assets/devices/water_freeze_sensor_t8920_large.png +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell2k_large.png +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbelldual_large.jpg +0 -0
- package/homebridge-ui/public/assets/icons/attach.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_0.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_1.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_2.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_3.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_4.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_5.svg +0 -1
- package/homebridge-ui/public/assets/icons/battery_6.svg +0 -1
- package/homebridge-ui/public/assets/icons/copy.svg +0 -1
- package/homebridge-ui/public/assets/icons/delete.svg +0 -1
- package/homebridge-ui/public/assets/icons/download.svg +0 -1
- package/homebridge-ui/public/assets/icons/info.svg +0 -1
- package/homebridge-ui/public/assets/icons/refresh.svg +0 -1
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +0 -1
- package/homebridge-ui/public/assets/icons/solar_power.svg +0 -1
- package/homebridge-ui/public/components/device-card.js +0 -174
- package/homebridge-ui/public/components/guard-modes.js +0 -88
- package/homebridge-ui/public/components/number-input.js +0 -121
- package/homebridge-ui/public/components/select.js +0 -73
- package/homebridge-ui/public/components/toggle.js +0 -68
- package/homebridge-ui/public/services/api.js +0 -250
- package/homebridge-ui/public/services/config.js +0 -144
- package/homebridge-ui/public/style.css +0 -818
- package/homebridge-ui/public/utils/countries.js +0 -73
- package/homebridge-ui/public/utils/device-images.js +0 -102
- package/homebridge-ui/public/utils/helpers.js +0 -115
- package/homebridge-ui/public/views/dashboard.js +0 -226
- package/homebridge-ui/public/views/device-detail.js +0 -654
- package/homebridge-ui/public/views/diagnostics.js +0 -402
- package/homebridge-ui/public/views/login.js +0 -650
- package/homebridge-ui/public/views/recordings.js +0 -319
- package/homebridge-ui/public/views/settings.js +0 -197
- package/homebridge-ui/public/views/unsupported-detail.js +0 -296
- package/media/Snapshot-Unavailable.png +0 -0
- package/media/Snapshot-Unavailable.xcf +0 -0
- package/media/Snapshot-black.png +0 -0
- package/media/camera-disabled.png +0 -0
- package/media/camera-offline.png +0 -0
- package/scripts/decrypt-diagnostics.mjs +0 -257
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
import { NightVision } from '@mega-yfue/eufy-sdk';
|
|
2
|
+
import { satisfiesMemberRequirements } from '../../device/member-evidence.js';
|
|
3
|
+
import { cameraEnablementReader, observesCameraEnablement, untrusted } from '../../device/member-trust.js';
|
|
4
|
+
import { deviceOperationIssuer, enablementAnnouncement, INVALID_OBSERVATION_CONDITION, observationReader, } from '../device-control.js';
|
|
5
|
+
import { isBatteryPowered } from './battery.js';
|
|
6
|
+
import { hasAdmittedDoorbellPress } from './doorbell.js';
|
|
7
|
+
import { hasAdmittedMotionEvents, motionSensorService } from './motion.js';
|
|
8
|
+
export const CAMERA_STREAMING_ADAPTER_KEY = 'camera.streaming';
|
|
9
|
+
/**
|
|
10
|
+
* The resolutions every camera advertises to a controller.
|
|
11
|
+
*
|
|
12
|
+
* One 16:9 ladder for every camera, whatever shape its own source produces. A camera of another shape has its
|
|
13
|
+
* picture scaled to fit and centred, which preserves its aspect and costs it no bit rate. Dimensions are even
|
|
14
|
+
* because H.264 codes in macroblocks, and the frame rate is a ceiling a session may select below rather than a
|
|
15
|
+
* target.
|
|
16
|
+
*/
|
|
17
|
+
const ADVERTISED_RESOLUTIONS = [
|
|
18
|
+
[320, 180, 15],
|
|
19
|
+
[640, 360, 30],
|
|
20
|
+
[1280, 720, 30],
|
|
21
|
+
[1920, 1080, 30],
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* The stable key of the Camera Operating Mode service this bundle publishes for a camera that configures no
|
|
25
|
+
* HomeKit Secure Video recording, and therefore has no controller-owned service to carry the presented
|
|
26
|
+
* state.
|
|
27
|
+
*/
|
|
28
|
+
export const CAMERA_OPERATING_MODE_SERVICE_KEY = 'camera.operating-mode';
|
|
29
|
+
const CAMERA_LIVE_SESSION_CONDITION = 'camera-live-session-failed';
|
|
30
|
+
const CAMERA_LIVE_REFUSED_CONDITION = 'camera-live-session-refused';
|
|
31
|
+
/**
|
|
32
|
+
* Why live view is unavailable for a camera the host has no room for, kept apart from the enablement refusal.
|
|
33
|
+
*
|
|
34
|
+
* The two cannot share a condition. They are withdrawn by different events, they name different things to do
|
|
35
|
+
* about them — one is a camera to switch on, the other a ceiling to raise or a viewer to close — and a camera
|
|
36
|
+
* cannot have two writers for why live view is unavailable, so a transient capacity refusal would otherwise
|
|
37
|
+
* clear the latch a disabled camera is holding.
|
|
38
|
+
*/
|
|
39
|
+
const CAMERA_MEDIA_AT_CAPACITY_CONDITION = 'camera-media-at-capacity';
|
|
40
|
+
const CAMERA_SNAPSHOT_UNAVAILABLE_CONDITION = 'camera-snapshot-unavailable';
|
|
41
|
+
const CAMERA_RECORDING_UNAVAILABLE_CONDITION = 'camera-recording-unavailable';
|
|
42
|
+
const CAMERA_RECORDING_FAILED_CONDITION = 'camera-recording-failed';
|
|
43
|
+
const CAMERA_RECORDING_REFUSED_CONDITION = 'camera-recording-refused';
|
|
44
|
+
const CAMERA_TALKBACK_UNAVAILABLE_CONDITION = 'camera-talkback-capability-unavailable';
|
|
45
|
+
const CAMERA_TALKBACK_FAILED_CONDITION = 'camera-talkback-failed';
|
|
46
|
+
/**
|
|
47
|
+
* The pre-event media window this camera advertises and, when it is eligible, retains in milliseconds.
|
|
48
|
+
* HomeKit requires an accessory to advertise at least four seconds, so this is the floor rather than a
|
|
49
|
+
* choice, and a camera that retains it never retains more than a controller can select.
|
|
50
|
+
*/
|
|
51
|
+
const RECORDING_PREBUFFER_MS = 4_000;
|
|
52
|
+
/** The fragment length this camera advertises, which is the value HomeKit Secure Video cameras use. */
|
|
53
|
+
const RECORDING_FRAGMENT_LENGTH_MS = 4_000;
|
|
54
|
+
/**
|
|
55
|
+
* The recorded audio sample rates this camera advertises, in kHz.
|
|
56
|
+
*
|
|
57
|
+
* A station sends 16 kHz mono, so every higher rate is resampled on the way out rather than being source
|
|
58
|
+
* truth. They are still advertised because the adaptation genuinely produces each of them, and a controller
|
|
59
|
+
* that finds no rate it accepts selects no recording configuration at all.
|
|
60
|
+
*/
|
|
61
|
+
const RECORDING_AUDIO_SAMPLE_RATES = [16, 24, 32, 48];
|
|
62
|
+
/** The HomeKit identifier for each advertised recorded audio sample rate. */
|
|
63
|
+
const RECORDING_SAMPLE_RATES = (hap) => ({
|
|
64
|
+
16: 1 /* hap.AudioRecordingSamplerate.KHZ_16 */,
|
|
65
|
+
24: 2 /* hap.AudioRecordingSamplerate.KHZ_24 */,
|
|
66
|
+
32: 3 /* hap.AudioRecordingSamplerate.KHZ_32 */,
|
|
67
|
+
48: 5 /* hap.AudioRecordingSamplerate.KHZ_48 */,
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* The indicator LED this bundle presents on the same service, and the operation that moves it. HomeKit
|
|
71
|
+
* calls it the camera operating mode indicator, which is what the Home app shows as the camera's status
|
|
72
|
+
* light, so it belongs on that service rather than on a switch of its own.
|
|
73
|
+
*/
|
|
74
|
+
const CAMERA_STATUS_LED_READ = { id: 'camera.statusLed.read', kind: 'read', type: 'bool', writable: true };
|
|
75
|
+
const CAMERA_STATUS_LED_WRITE = { id: 'camera.statusLed.persistent-operation', kind: 'persistent-operation' };
|
|
76
|
+
/**
|
|
77
|
+
* Night vision, presented on the same service. The SDK reports it as a three-value mode and HomeKit carries
|
|
78
|
+
* one boolean, so the projection is this plugin's: see {@link nightVisionPresentation}.
|
|
79
|
+
*/
|
|
80
|
+
const CAMERA_NIGHT_VISION_READ = { id: 'camera.nightVision.read', kind: 'read', type: 'enum', writable: true };
|
|
81
|
+
const CAMERA_NIGHT_VISION_WRITE = {
|
|
82
|
+
id: 'camera.nightVision.persistent-operation',
|
|
83
|
+
kind: 'persistent-operation',
|
|
84
|
+
};
|
|
85
|
+
/** The operation that turns this camera off and on again, which HomeKit's own camera-active state drives. */
|
|
86
|
+
const CAMERA_ENABLED_WRITE = { id: 'camera.enabled.persistent-operation', kind: 'persistent-operation' };
|
|
87
|
+
/** The night-vision modes the SDK declares, which are the only readings this plugin will project. */
|
|
88
|
+
const NIGHT_VISION_MODES = new Set(Object.values(NightVision));
|
|
89
|
+
/**
|
|
90
|
+
* How often an active live session re-reads the enablement observation.
|
|
91
|
+
*
|
|
92
|
+
* The SDK announces an enablement change, and a session ends on that announcement rather than on this tick,
|
|
93
|
+
* so this is the backstop for a change no announcement reached: the push event covers only a write the SDK
|
|
94
|
+
* itself issued, and the poll event only fires as often as the account is polled. The read is an in-memory
|
|
95
|
+
* one whose own staleness policy bounds how often it reaches the network, so the tick period buys detection
|
|
96
|
+
* latency without buying requests.
|
|
97
|
+
*/
|
|
98
|
+
const ENABLEMENT_SUPERVISION_INTERVAL_MS = 5_000;
|
|
99
|
+
/**
|
|
100
|
+
* How long a started request is given to reach an outcome before it is reported as having reached none.
|
|
101
|
+
*
|
|
102
|
+
* Comfortably past the longest legitimate start: a camera on a base costs a second or two to acquire, and the
|
|
103
|
+
* adaptation a moment more. Anything still owing an outcome here is not slow, it is lost.
|
|
104
|
+
*/
|
|
105
|
+
const UNACCOUNTED_REQUEST_MS = 30_000;
|
|
106
|
+
const CAMERA_LIVE = {
|
|
107
|
+
id: 'camera.live.momentary-action',
|
|
108
|
+
kind: 'momentary-action',
|
|
109
|
+
};
|
|
110
|
+
const CAMERA_SNAPSHOT_STORED = {
|
|
111
|
+
id: 'camera.snapshotStored.momentary-action',
|
|
112
|
+
kind: 'momentary-action',
|
|
113
|
+
};
|
|
114
|
+
const CAMERA_SNAPSHOT_LIVE = {
|
|
115
|
+
id: 'camera.snapshotLive.momentary-action',
|
|
116
|
+
kind: 'momentary-action',
|
|
117
|
+
};
|
|
118
|
+
const CAMERA_RECORD_FRAGMENTS = {
|
|
119
|
+
id: 'camera.recordFragments.momentary-action',
|
|
120
|
+
kind: 'momentary-action',
|
|
121
|
+
};
|
|
122
|
+
const CAMERA_TALKBACK = {
|
|
123
|
+
id: 'camera.talkback.momentary-action',
|
|
124
|
+
kind: 'momentary-action',
|
|
125
|
+
};
|
|
126
|
+
const CAMERA_STREAMING_OWNERS = new WeakMap();
|
|
127
|
+
const CAMERA_STREAMING_STATES = new WeakMap();
|
|
128
|
+
/** Complete HomeKit RTP policy for an exactly evidenced SDK live-camera action. */
|
|
129
|
+
export const CAMERA_STREAMING_ADAPTER = {
|
|
130
|
+
key: CAMERA_STREAMING_ADAPTER_KEY,
|
|
131
|
+
role: 'primary-purpose',
|
|
132
|
+
requires: [CAMERA_LIVE],
|
|
133
|
+
coverage: [
|
|
134
|
+
CAMERA_LIVE.id,
|
|
135
|
+
CAMERA_TALKBACK.id,
|
|
136
|
+
CAMERA_SNAPSHOT_STORED.id,
|
|
137
|
+
CAMERA_SNAPSHOT_LIVE.id,
|
|
138
|
+
CAMERA_RECORD_FRAGMENTS.id,
|
|
139
|
+
CAMERA_STATUS_LED_READ.id,
|
|
140
|
+
CAMERA_STATUS_LED_WRITE.id,
|
|
141
|
+
CAMERA_NIGHT_VISION_READ.id,
|
|
142
|
+
CAMERA_NIGHT_VISION_WRITE.id,
|
|
143
|
+
CAMERA_ENABLED_WRITE.id,
|
|
144
|
+
],
|
|
145
|
+
attach: attachCameraStreaming,
|
|
146
|
+
};
|
|
147
|
+
function attachCameraStreaming(context) {
|
|
148
|
+
const device = context.device;
|
|
149
|
+
let camera;
|
|
150
|
+
try {
|
|
151
|
+
camera = device.camera?.();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
context.diagnose(unavailable(true, 'sdk-fault'));
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (!camera || typeof camera.live !== 'function' || !context.liveMedia) {
|
|
158
|
+
context.diagnose(unavailable(true, !camera || typeof camera.live !== 'function' ? 'missing' : 'adapter-missing'));
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
context.diagnose(unavailable(false, 'recovered'));
|
|
162
|
+
const snapshotMode = context.snapshotMode ?? 'Refresh';
|
|
163
|
+
const storedEvidence = context.evidence.has(CAMERA_SNAPSHOT_STORED.id);
|
|
164
|
+
const liveEvidence = context.evidence.has(CAMERA_SNAPSHOT_LIVE.id);
|
|
165
|
+
const storedAvailable = storedEvidence && typeof camera.snapshotStored === 'function';
|
|
166
|
+
const liveAvailable = liveEvidence && typeof camera.snapshotLive === 'function';
|
|
167
|
+
const storedReason = !context.snapshotMedia
|
|
168
|
+
? 'adapter-missing'
|
|
169
|
+
: !storedEvidence
|
|
170
|
+
? 'missing-evidence'
|
|
171
|
+
: storedAvailable
|
|
172
|
+
? 'recovered'
|
|
173
|
+
: 'missing';
|
|
174
|
+
const liveReason = !context.snapshotMedia
|
|
175
|
+
? 'adapter-missing'
|
|
176
|
+
: !liveEvidence
|
|
177
|
+
? 'missing-evidence'
|
|
178
|
+
: liveAvailable
|
|
179
|
+
? 'recovered'
|
|
180
|
+
: 'missing';
|
|
181
|
+
const requiresStored = snapshotMode === 'Cloud' || (snapshotMode === 'Refresh' && !liveAvailable);
|
|
182
|
+
const requiresLive = snapshotMode === 'Live' || (snapshotMode === 'Refresh' && !storedAvailable);
|
|
183
|
+
context.diagnose(snapshotUnavailable(requiresStored && storedReason !== 'recovered', 'snapshotStored', storedReason));
|
|
184
|
+
context.diagnose(snapshotUnavailable(requiresLive && liveReason !== 'recovered', 'snapshotLive', liveReason));
|
|
185
|
+
const recordingEvidence = context.evidence.has(CAMERA_RECORD_FRAGMENTS.id);
|
|
186
|
+
const recordingAvailable = recordingEvidence && typeof camera.recordFragments === 'function';
|
|
187
|
+
const recordingMotion = hasAdmittedMotionEvents(context.evidence);
|
|
188
|
+
const recordingPress = hasAdmittedDoorbellPress(context.evidence);
|
|
189
|
+
const recordingConfigured = recordingAvailable && context.recordingMedia !== undefined && (recordingMotion || recordingPress);
|
|
190
|
+
const talkbackEvidence = context.evidence.has(CAMERA_TALKBACK.id);
|
|
191
|
+
const talkbackConfigured = talkbackEvidence && typeof camera.talkback === 'function' && context.audioEnabled !== false;
|
|
192
|
+
context.diagnose({
|
|
193
|
+
code: CAMERA_TALKBACK_UNAVAILABLE_CONDITION,
|
|
194
|
+
capability: 'camera',
|
|
195
|
+
member: 'talkback',
|
|
196
|
+
active: context.audioEnabled !== false && talkbackEvidence && !talkbackConfigured,
|
|
197
|
+
reason: context.audioEnabled !== false && talkbackEvidence && !talkbackConfigured ? 'missing' : 'recovered',
|
|
198
|
+
});
|
|
199
|
+
context.diagnose({
|
|
200
|
+
code: CAMERA_RECORDING_UNAVAILABLE_CONDITION,
|
|
201
|
+
capability: 'camera',
|
|
202
|
+
member: 'recordFragments',
|
|
203
|
+
active: !recordingConfigured,
|
|
204
|
+
reason: recordingConfigured
|
|
205
|
+
? 'recovered'
|
|
206
|
+
: !recordingEvidence
|
|
207
|
+
? 'missing-evidence'
|
|
208
|
+
: !recordingAvailable
|
|
209
|
+
? 'missing'
|
|
210
|
+
: context.recordingMedia === undefined
|
|
211
|
+
? 'adapter-missing'
|
|
212
|
+
: 'missing-trigger',
|
|
213
|
+
});
|
|
214
|
+
const prebufferLengthMs = recordingConfigured ? retainedPrebufferMs(context.evidence) : 0;
|
|
215
|
+
const liveSourceOptions = prebufferLengthMs > 0 ? { preBufferSeconds: prebufferLengthMs / 1_000 } : undefined;
|
|
216
|
+
const openLiveSource = camera.live.bind(camera);
|
|
217
|
+
const observed = observesCameraEnablement(camera, context.evidence);
|
|
218
|
+
const enablement = cameraEnablementReader(camera, context.evidence);
|
|
219
|
+
const detachRejectors = new Set();
|
|
220
|
+
let detached = false;
|
|
221
|
+
const readBoolean = observationReader(context, 'boolean');
|
|
222
|
+
const readNumber = observationReader(context, 'number');
|
|
223
|
+
const reportAdmission = cameraCondition(context, CAMERA_LIVE_REFUSED_CONDITION, 'live');
|
|
224
|
+
const reportCapacity = cameraCondition(context, CAMERA_MEDIA_AT_CAPACITY_CONDITION, 'live');
|
|
225
|
+
const acquireLiveSnapshot = liveAvailable ? camera.snapshotLive.bind(camera) : undefined;
|
|
226
|
+
const openTalkback = talkbackConfigured ? camera.talkback.bind(camera) : undefined;
|
|
227
|
+
const source = {
|
|
228
|
+
live: () => openLiveSource(liveSourceOptions),
|
|
229
|
+
...(storedAvailable ? { snapshotStored: camera.snapshotStored.bind(camera) } : {}),
|
|
230
|
+
...(acquireLiveSnapshot ? { snapshotLive: () => acquireLiveSnapshot(liveSourceOptions) } : {}),
|
|
231
|
+
...(recordingAvailable ? { recordFragments: camera.recordFragments.bind(camera) } : {}),
|
|
232
|
+
...(openTalkback ? { talkback: () => openTalkback(liveSourceOptions) } : {}),
|
|
233
|
+
};
|
|
234
|
+
const existing = CAMERA_STREAMING_STATES.get(context.accessory);
|
|
235
|
+
const owner = Symbol('camera-streaming-owner');
|
|
236
|
+
const operations = existing?.operations ?? {
|
|
237
|
+
owner,
|
|
238
|
+
activeOperations: new Map(),
|
|
239
|
+
blockedOperations: new Set(),
|
|
240
|
+
};
|
|
241
|
+
operations.owner = owner;
|
|
242
|
+
const issue = deviceOperationIssuer({
|
|
243
|
+
context,
|
|
244
|
+
state: operations,
|
|
245
|
+
owned: () => CAMERA_STREAMING_OWNERS.get(context.accessory) === owner,
|
|
246
|
+
detached: () => detached,
|
|
247
|
+
detachRejectors,
|
|
248
|
+
});
|
|
249
|
+
/** Everything the operating mode service presents and operates, resolved once per attachment. */
|
|
250
|
+
const controls = { camera, enablement, observed, issue, readBoolean, readNumber };
|
|
251
|
+
/** Wakes every write still in flight when this attachment stops being the one HomeKit is talking to. */
|
|
252
|
+
const releaseOperations = () => {
|
|
253
|
+
detached = true;
|
|
254
|
+
const error = new context.hap.HapStatusError(context.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
255
|
+
for (const reject of detachRejectors) {
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
detachRejectors.clear();
|
|
259
|
+
};
|
|
260
|
+
const binding = {
|
|
261
|
+
serial: context.device.sn,
|
|
262
|
+
source,
|
|
263
|
+
media: context.liveMedia,
|
|
264
|
+
...(context.snapshotMedia ? { snapshotMedia: context.snapshotMedia } : {}),
|
|
265
|
+
audioEnabled: context.audioEnabled !== false,
|
|
266
|
+
snapshotMode,
|
|
267
|
+
enablement,
|
|
268
|
+
availability: availabilityObservation(context.device.sn, context.availability),
|
|
269
|
+
reportSession: liveSessionReporter(context, reportAdmission),
|
|
270
|
+
reportRelease: () => context.trace?.({ event: 'live-session-released' }),
|
|
271
|
+
reportTalkback: talkbackReporter(context),
|
|
272
|
+
reportAdmission,
|
|
273
|
+
reportCapacity,
|
|
274
|
+
...(context.mediaBudget ? { budget: context.mediaBudget } : {}),
|
|
275
|
+
reportSnapshot: cameraCondition(context, CAMERA_SNAPSHOT_UNAVAILABLE_CONDITION, 'snapshot'),
|
|
276
|
+
reportSelection: context.trace,
|
|
277
|
+
...(context.device.stationSn ? { stationSn: context.device.stationSn } : {}),
|
|
278
|
+
...(context.stationLiveSessions ? { stations: context.stationLiveSessions } : {}),
|
|
279
|
+
};
|
|
280
|
+
const recordingBinding = recordingConfigured
|
|
281
|
+
? {
|
|
282
|
+
serial: context.device.sn,
|
|
283
|
+
...(binding.stationSn ? { stationSn: binding.stationSn } : {}),
|
|
284
|
+
...(binding.stations ? { stations: binding.stations } : {}),
|
|
285
|
+
source,
|
|
286
|
+
media: context.recordingMedia,
|
|
287
|
+
audioEnabled: context.audioEnabled !== false,
|
|
288
|
+
prebufferLengthMs,
|
|
289
|
+
enablement: binding.enablement,
|
|
290
|
+
reportRecording: recordingReporter(context),
|
|
291
|
+
reportAdmission: cameraCondition(context, CAMERA_RECORDING_REFUSED_CONDITION, 'recordFragments'),
|
|
292
|
+
}
|
|
293
|
+
: undefined;
|
|
294
|
+
if (existing && existing.audio === binding.audioEnabled && existing.talkback === talkbackConfigured) {
|
|
295
|
+
existing.owner = owner;
|
|
296
|
+
existing.operations = operations;
|
|
297
|
+
existing.delegate.update(binding);
|
|
298
|
+
existing.recording?.update(recordingBinding);
|
|
299
|
+
CAMERA_STREAMING_OWNERS.set(context.accessory, owner);
|
|
300
|
+
return attachment(context, existing.controller, existing.delegate, existing.recording, owner, controls, releaseOperations);
|
|
301
|
+
}
|
|
302
|
+
if (existing) {
|
|
303
|
+
existing.delegate.stop();
|
|
304
|
+
existing.recording?.stop();
|
|
305
|
+
context.accessory.removeController(existing.controller);
|
|
306
|
+
if (!talkbackConfigured) {
|
|
307
|
+
context.observed(CAMERA_TALKBACK_FAILED_CONDITION);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const delegate = new LiveCameraDelegate(device.sn, binding, context.hap);
|
|
311
|
+
const recording = recordingConfigured ? new RecordingCameraDelegate(recordingBinding, context.hap) : undefined;
|
|
312
|
+
const controller = new context.hap.CameraController({
|
|
313
|
+
cameraStreamCount: 2,
|
|
314
|
+
delegate,
|
|
315
|
+
streamingOptions: {
|
|
316
|
+
supportedCryptoSuites: [0 /* context.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80 */],
|
|
317
|
+
video: {
|
|
318
|
+
codec: {
|
|
319
|
+
profiles: [0 /* context.hap.H264Profile.BASELINE */, 1 /* context.hap.H264Profile.MAIN */, 2 /* context.hap.H264Profile.HIGH */],
|
|
320
|
+
levels: [0 /* context.hap.H264Level.LEVEL3_1 */, 1 /* context.hap.H264Level.LEVEL3_2 */, 2 /* context.hap.H264Level.LEVEL4_0 */],
|
|
321
|
+
},
|
|
322
|
+
resolutions: ADVERTISED_RESOLUTIONS.map((entry) => [...entry]),
|
|
323
|
+
},
|
|
324
|
+
...(context.audioEnabled === false
|
|
325
|
+
? {}
|
|
326
|
+
: {
|
|
327
|
+
audio: {
|
|
328
|
+
twoWayAudio: talkbackConfigured,
|
|
329
|
+
codecs: [
|
|
330
|
+
{
|
|
331
|
+
type: "AAC-eld" /* context.hap.AudioStreamingCodecType.AAC_ELD */,
|
|
332
|
+
audioChannels: 1,
|
|
333
|
+
bitrate: 0,
|
|
334
|
+
samplerate: talkbackConfigured
|
|
335
|
+
? [16 /* context.hap.AudioStreamingSamplerate.KHZ_16 */]
|
|
336
|
+
: [16 /* context.hap.AudioStreamingSamplerate.KHZ_16 */, 24 /* context.hap.AudioStreamingSamplerate.KHZ_24 */],
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
},
|
|
342
|
+
...(recording
|
|
343
|
+
? {
|
|
344
|
+
recording: { options: recordingOptions(context, recordingPress), delegate: recording },
|
|
345
|
+
...(recordingMotion ? { sensors: { motion: motionSensorService(context) } } : {}),
|
|
346
|
+
}
|
|
347
|
+
: {}),
|
|
348
|
+
}, true);
|
|
349
|
+
delegate.controller = controller;
|
|
350
|
+
if (recording) {
|
|
351
|
+
removeOperatingMode(context);
|
|
352
|
+
}
|
|
353
|
+
context.accessory.configureController(controller);
|
|
354
|
+
if (recording) {
|
|
355
|
+
recording.recordingManagement = controller.recordingManagement;
|
|
356
|
+
}
|
|
357
|
+
CAMERA_STREAMING_STATES.set(context.accessory, {
|
|
358
|
+
owner,
|
|
359
|
+
controller,
|
|
360
|
+
delegate,
|
|
361
|
+
operations,
|
|
362
|
+
audio: binding.audioEnabled,
|
|
363
|
+
talkback: talkbackConfigured,
|
|
364
|
+
...(recording ? { recording } : {}),
|
|
365
|
+
});
|
|
366
|
+
CAMERA_STREAMING_OWNERS.set(context.accessory, owner);
|
|
367
|
+
return attachment(context, controller, delegate, recording, owner, controls, releaseOperations);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* What this camera advertises as recordable.
|
|
371
|
+
*
|
|
372
|
+
* The motion trigger is derived from the sensor service the controller is given rather than declared
|
|
373
|
+
* separately, because HomeKit needs the sensor that triggers a recording to be linked to the recording
|
|
374
|
+
* management service, carry its own active state, and follow the camera's HomeKit-active state. Declaring
|
|
375
|
+
* the trigger alone advertises a recording nothing will ever start. That service is resolved through the
|
|
376
|
+
* detection adapter that owns it, so one accessory still carries exactly one motion service whichever of
|
|
377
|
+
* the two attaches first.
|
|
378
|
+
*
|
|
379
|
+
* A press trigger is declared rather than derived, and that asymmetry with motion is deliberate. HomeKit
|
|
380
|
+
* links nothing for a press and mirrors no state onto the doorbell service, so the advertised trigger is the
|
|
381
|
+
* whole mechanism; the service carrying the press stays wholly owned by the doorbell adapter. Deriving it
|
|
382
|
+
* instead would mean building the doorbell controller, which cannot be told to leave the microphone and
|
|
383
|
+
* speaker services alone and would put a second one of each on an accessory that already has them.
|
|
384
|
+
*
|
|
385
|
+
* Whether a home hub enables a press trigger is the hub's decision to publish in the configuration it
|
|
386
|
+
* selects, so it is advertised wherever a press exists rather than asserted here.
|
|
387
|
+
*
|
|
388
|
+
* Only the resolutions and rates a recording can actually be coded at are advertised, and only AAC-ELD,
|
|
389
|
+
* so a controller cannot select a contract the adaptation would then have to approximate.
|
|
390
|
+
*/
|
|
391
|
+
function recordingOptions(context, press) {
|
|
392
|
+
const { hap } = context;
|
|
393
|
+
return {
|
|
394
|
+
prebufferLength: RECORDING_PREBUFFER_MS,
|
|
395
|
+
...(press ? { overrideEventTriggerOptions: [2 /* hap.EventTriggerOption.DOORBELL */] } : {}),
|
|
396
|
+
mediaContainerConfiguration: {
|
|
397
|
+
type: 0 /* hap.MediaContainerType.FRAGMENTED_MP4 */,
|
|
398
|
+
fragmentLength: RECORDING_FRAGMENT_LENGTH_MS,
|
|
399
|
+
},
|
|
400
|
+
video: {
|
|
401
|
+
type: 0 /* hap.VideoCodecType.H264 */,
|
|
402
|
+
parameters: {
|
|
403
|
+
profiles: [0 /* hap.H264Profile.BASELINE */, 1 /* hap.H264Profile.MAIN */, 2 /* hap.H264Profile.HIGH */],
|
|
404
|
+
levels: [0 /* hap.H264Level.LEVEL3_1 */, 1 /* hap.H264Level.LEVEL3_2 */, 2 /* hap.H264Level.LEVEL4_0 */],
|
|
405
|
+
},
|
|
406
|
+
resolutions: [
|
|
407
|
+
[1280, 720, 15],
|
|
408
|
+
[1280, 720, 30],
|
|
409
|
+
[1920, 1080, 15],
|
|
410
|
+
[1920, 1080, 30],
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
audio: {
|
|
414
|
+
codecs: [0 /* hap.AudioRecordingCodecType.AAC_LC */, 1 /* hap.AudioRecordingCodecType.AAC_ELD */].map((type) => ({
|
|
415
|
+
type,
|
|
416
|
+
audioChannels: 1,
|
|
417
|
+
bitrateMode: 0 /* hap.AudioBitrate.VARIABLE */,
|
|
418
|
+
samplerate: RECORDING_AUDIO_SAMPLE_RATES.map((rate) => RECORDING_SAMPLE_RATES(hap)[rate]),
|
|
419
|
+
})),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* How much pre-event media a camera admitted to HomeKit Secure Video retains on its shared source, in
|
|
425
|
+
* milliseconds. A camera with no recording to serve retains none, because a window nothing can drain is
|
|
426
|
+
* only memory.
|
|
427
|
+
*
|
|
428
|
+
* Only a mains-powered camera retains any. The window is a rolling buffer of media the source was already
|
|
429
|
+
* carrying, and it is configured on the source rather than kept warm for it, so a camera nobody is watching
|
|
430
|
+
* still answers a recording from its trigger: nothing here opens a stream to fill a buffer. A battery or
|
|
431
|
+
* solar camera is withheld from it entirely, because the only way its buffer would ever hold anything is a
|
|
432
|
+
* stream held open on its own power.
|
|
433
|
+
*
|
|
434
|
+
* The window belongs to whichever consumer opens the shared source, so live view asks for the same one a
|
|
435
|
+
* recording drains: measured on a wired camera, a source opened with the window handed a recording media it
|
|
436
|
+
* had captured before the recording attached, and the same window asked for only at recording time on a
|
|
437
|
+
* source opened without it delivered none. Live snapshots ask for the same window because they can also be
|
|
438
|
+
* the call that opens the source; no source-creating path may silently decide that later recordings retain
|
|
439
|
+
* nothing.
|
|
440
|
+
*/
|
|
441
|
+
function retainedPrebufferMs(evidence) {
|
|
442
|
+
return isBatteryPowered(evidence) ? 0 : RECORDING_PREBUFFER_MS;
|
|
443
|
+
}
|
|
444
|
+
function attachment(context, controller, delegate, recording, owner, controls, releaseOperations) {
|
|
445
|
+
const presentation = operatingModePresentation(context, controller, controls);
|
|
446
|
+
return {
|
|
447
|
+
/**
|
|
448
|
+
* Follows an announced enablement change: a session watching a camera which just went off is ended at
|
|
449
|
+
* once rather than at the next supervision read, and the trace states whether the reading behind that
|
|
450
|
+
* decision answered.
|
|
451
|
+
*/
|
|
452
|
+
event(event) {
|
|
453
|
+
const trace = enablementAnnouncement(context, event, presentation.observe);
|
|
454
|
+
if (trace === undefined) {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
delegate.observeEnablement();
|
|
458
|
+
return trace;
|
|
459
|
+
},
|
|
460
|
+
detach(reason) {
|
|
461
|
+
if (CAMERA_STREAMING_OWNERS.get(context.accessory) !== owner) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
delegate.stop();
|
|
465
|
+
recording?.stop();
|
|
466
|
+
releaseOperations();
|
|
467
|
+
if (reason === 'shutdown') {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
CAMERA_STREAMING_OWNERS.delete(context.accessory);
|
|
471
|
+
CAMERA_STREAMING_STATES.delete(context.accessory);
|
|
472
|
+
context.accessory.removeController(controller);
|
|
473
|
+
presentation.remove();
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function unavailable(active, reason) {
|
|
478
|
+
return {
|
|
479
|
+
code: 'camera-streaming-capability-unavailable',
|
|
480
|
+
capability: 'camera',
|
|
481
|
+
member: 'live',
|
|
482
|
+
active,
|
|
483
|
+
reason,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function snapshotUnavailable(active, member, reason) {
|
|
487
|
+
return {
|
|
488
|
+
code: 'camera-snapshot-capability-unavailable',
|
|
489
|
+
capability: 'camera',
|
|
490
|
+
member,
|
|
491
|
+
active,
|
|
492
|
+
reason: active ? reason : 'recovered',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Latches why the most recent live session ended without usable video and clears that condition once a
|
|
497
|
+
* later session reaches the negotiated output.
|
|
498
|
+
*
|
|
499
|
+
* A session a camera answered with audio and never a video frame is reported as that camera being switched
|
|
500
|
+
* off, not as a transport failure, when its admitted observation says it is off: measured on a real camera
|
|
501
|
+
* that had been turned off, the source accepted the start, delivered audio for the whole warm-up window and
|
|
502
|
+
* reported the SDK's `audio-only` stage, which is the corroboration where a reading taken before the
|
|
503
|
+
* session could have been stale. The caller decides that, because it holds the latch which decides whether
|
|
504
|
+
* the same refusal has already been reported; the enablement refusal condition itself is the one the admission
|
|
505
|
+
* gate reports through, so that condition cannot have two writers. A refusal for host capacity is a separate
|
|
506
|
+
* condition with a writer of its own, because the two are withdrawn by different events.
|
|
507
|
+
*/
|
|
508
|
+
function liveSessionReporter(context, refusal) {
|
|
509
|
+
const condition = cameraCondition(context, CAMERA_LIVE_SESSION_CONDITION, 'live');
|
|
510
|
+
return (outcome, switchedOff) => {
|
|
511
|
+
if (outcome.outcome === 'streaming') {
|
|
512
|
+
condition();
|
|
513
|
+
context.trace?.({ event: 'live-session-streaming' });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
context.trace?.({ event: 'live-session-failed', ...outcome });
|
|
517
|
+
if (switchedOff) {
|
|
518
|
+
refusal('disabled-no-video');
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
condition(outcome.reason);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Latches one bounded reason for a camera live condition and withdraws it when the condition recovers.
|
|
526
|
+
* Only the reason vocabulary its caller owns is reported; no device identity, address, key, media byte, or
|
|
527
|
+
* SDK message crosses this seam.
|
|
528
|
+
*/
|
|
529
|
+
function cameraCondition(context, code, member) {
|
|
530
|
+
return (reason) => {
|
|
531
|
+
if (reason === undefined) {
|
|
532
|
+
context.observed(code);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
context.diagnose({ code, capability: 'camera', member, active: true, reason });
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Latches why the most recent recording produced no usable output and clears that condition once a later
|
|
540
|
+
* recording produces some.
|
|
541
|
+
*/
|
|
542
|
+
function recordingReporter(context) {
|
|
543
|
+
const condition = cameraCondition(context, CAMERA_RECORDING_FAILED_CONDITION, 'recordFragments');
|
|
544
|
+
return (outcome) => condition(outcome.outcome === 'recording' ? undefined : outcome.reason);
|
|
545
|
+
}
|
|
546
|
+
/** Latches an isolated return-audio failure and clears it when later talkback starts producing audio. */
|
|
547
|
+
function talkbackReporter(context) {
|
|
548
|
+
const condition = cameraCondition(context, CAMERA_TALKBACK_FAILED_CONDITION, 'talkback');
|
|
549
|
+
return (outcome) => condition(outcome.outcome === 'talking' ? undefined : outcome.reason);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Attaches the controls this bundle owns on the one Camera Operating Mode service the accessory carries.
|
|
553
|
+
*
|
|
554
|
+
* A camera configured for HomeKit Secure Video already carries that service, created and owned by the HAP
|
|
555
|
+
* recording controller, which documents attaching an optional characteristic to it rather than adding a
|
|
556
|
+
* second service; a camera with no recording carries none, so this bundle adds one under its own stable
|
|
557
|
+
* key. Exactly one may exist, because HAP identifies a service by type and subtype and the controller's own
|
|
558
|
+
* carries an empty subtype: a plugin-owned service surviving from a run without recording would make the
|
|
559
|
+
* controller's own service fail to attach.
|
|
560
|
+
*
|
|
561
|
+
* The camera's own power is deliberately not published here as HomeKit's `ManuallyDisabled`, and an accessory
|
|
562
|
+
* restored from a version that published it has that state withdrawn along with the record kept for it. Apple
|
|
563
|
+
* Home answers a camera reporting it by declining to write that camera's operating mode at all, so publishing
|
|
564
|
+
* it costs the ability to switch the camera on from HomeKit — see `docs/architecture.md`. The power is offered
|
|
565
|
+
* as the Camera Enabled switch instead, and a session a disabled camera cannot serve is still refused by its
|
|
566
|
+
* own gate under a named reason.
|
|
567
|
+
*/
|
|
568
|
+
function operatingModePresentation(context, controller, { camera, enablement, observed, issue, readBoolean, readNumber }) {
|
|
569
|
+
const { hap } = context;
|
|
570
|
+
const indicated = indicatesStatusLed(context, camera);
|
|
571
|
+
const nightVisible = presentsNightVision(context, camera);
|
|
572
|
+
/** Whether HomeKit's camera-active state is carried to the device, which needs a reading to compare against. */
|
|
573
|
+
const carriesCameraActive = observed && satisfiesMemberRequirements(context.evidence, [CAMERA_ENABLED_WRITE]);
|
|
574
|
+
let service;
|
|
575
|
+
let hooked = false;
|
|
576
|
+
const resolve = () => {
|
|
577
|
+
service ??= operatingModeService(context, controller);
|
|
578
|
+
if (!hooked) {
|
|
579
|
+
hooked = true;
|
|
580
|
+
if (carriesCameraActive) {
|
|
581
|
+
homeKitActiveOperation(context, service, { camera, enablement, issue });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return service;
|
|
585
|
+
};
|
|
586
|
+
withdrawCharacteristic(context, controller, 'ManuallyDisabled');
|
|
587
|
+
const retained = context.accessory.context;
|
|
588
|
+
if (retained?.homebridgeEufyCameraHomeKitActive !== undefined) {
|
|
589
|
+
delete retained.homebridgeEufyCameraHomeKitActive;
|
|
590
|
+
context.persist();
|
|
591
|
+
}
|
|
592
|
+
if (!indicated) {
|
|
593
|
+
withdrawCharacteristic(context, controller, 'CameraOperatingModeIndicator');
|
|
594
|
+
}
|
|
595
|
+
if (!nightVisible) {
|
|
596
|
+
withdrawCharacteristic(context, controller, 'NightVision');
|
|
597
|
+
}
|
|
598
|
+
if (!carriesCameraActive && !indicated && !nightVisible) {
|
|
599
|
+
removeOperatingMode(context);
|
|
600
|
+
return { observe: enablementTrace(enablement), remove: () => undefined };
|
|
601
|
+
}
|
|
602
|
+
if (indicated) {
|
|
603
|
+
const read = () => readBoolean('camera', 'statusLed', () => camera.statusLed);
|
|
604
|
+
const indicator = resolve().getCharacteristic(hap.Characteristic.CameraOperatingModeIndicator);
|
|
605
|
+
indicator.onGet(read);
|
|
606
|
+
indicator.onSet((value) => {
|
|
607
|
+
if (typeof value !== 'boolean') {
|
|
608
|
+
throw new hap.HapStatusError(hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
609
|
+
}
|
|
610
|
+
return issue('camera', 'statusLed', () => camera.setStatusLed(value), () => {
|
|
611
|
+
try {
|
|
612
|
+
indicator.updateValue(read());
|
|
613
|
+
}
|
|
614
|
+
catch { }
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (nightVisible) {
|
|
619
|
+
nightVisionPresentation(context, resolve(), { camera, issue, readNumber });
|
|
620
|
+
}
|
|
621
|
+
resolve();
|
|
622
|
+
return {
|
|
623
|
+
observe: enablementTrace(enablement),
|
|
624
|
+
remove() {
|
|
625
|
+
removeOperatingMode(context);
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Reports whether the enablement observation answered, for the one trace an announced change records.
|
|
631
|
+
*
|
|
632
|
+
* The event is still acted on — a session watching a camera that just went off is ended at once — so the
|
|
633
|
+
* trace states whether the reading behind that decision was there, without publishing anything from it.
|
|
634
|
+
*/
|
|
635
|
+
function enablementTrace(enablement) {
|
|
636
|
+
return () => (enablement() === undefined ? 'missing' : 'valid');
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Carries HomeKit's own camera-active state through to the camera's power, where the camera accepts it.
|
|
640
|
+
*
|
|
641
|
+
* HomeKit owns this state: the Home app writes it when a camera is set to off for the mode the home is in,
|
|
642
|
+
* and where HomeKit Secure Video created the service HAP gates streams, snapshots and recordings on it
|
|
643
|
+
* itself — on a service this bundle publishes instead, HAP gates nothing and the state is carried only here.
|
|
644
|
+
* Turning the camera off physically as well is this plugin's product policy, because a camera the user has
|
|
645
|
+
* told HomeKit not to use is a camera they have asked not to be watched by, and leaving it powered means it
|
|
646
|
+
* keeps recording to the vendor's cloud.
|
|
647
|
+
*
|
|
648
|
+
* Two things must be true before the camera is written:
|
|
649
|
+
*
|
|
650
|
+
* - **A controller wrote it.** An internal write carries no connection, so seeding a required state or
|
|
651
|
+
* restoring one cannot reach the device.
|
|
652
|
+
* - **The camera disagrees.** A camera already off is not told to turn off, which also stops a command that
|
|
653
|
+
* cannot succeed from being reissued on every reconnection.
|
|
654
|
+
*
|
|
655
|
+
* A value a controller merely re-asserts is deliberately not rejected. HomeKit decides whether this camera is
|
|
656
|
+
* on, so the state it holds is applied to a camera that disagrees with it however that state arrived — which
|
|
657
|
+
* is also what reconciles the device after a restart, since the home hub re-asserts its per-mode setting when
|
|
658
|
+
* the bridge reappears. Requiring the value to have moved instead left a divergence no action in the Home app
|
|
659
|
+
* could resolve, because the only value a user can write is the one HomeKit already holds.
|
|
660
|
+
*
|
|
661
|
+
* A camera whose power this plugin cannot write still accepts the state, because refusing it would leave the
|
|
662
|
+
* user unable to turn the camera off in HomeKit at all; the write is simply HomeKit's own then. A camera that
|
|
663
|
+
* refuses the change has it reverted to what the camera reports, so HomeKit never keeps a claim the device did
|
|
664
|
+
* not reach.
|
|
665
|
+
*/
|
|
666
|
+
function homeKitActiveOperation(context, service, { camera, enablement, issue }) {
|
|
667
|
+
const { hap } = context;
|
|
668
|
+
const operable = satisfiesMemberRequirements(context.evidence, [CAMERA_ENABLED_WRITE]) &&
|
|
669
|
+
typeof camera.setEnabled === 'function' &&
|
|
670
|
+
!untrusted(camera, 'enabled');
|
|
671
|
+
const active = service.getCharacteristic(hap.Characteristic.HomeKitCameraActive);
|
|
672
|
+
/**
|
|
673
|
+
* Answers HomeKit with the state HomeKit itself holds here, which this bundle only carries.
|
|
674
|
+
*
|
|
675
|
+
* The value is not this plugin's to decide, but the read must be answered all the same: HAP throws the
|
|
676
|
+
* status a failed write left on a characteristic that registers no read handler, on every later read and
|
|
677
|
+
* for good, so a camera whose power refused one write would be reported unresponsive until the bridge
|
|
678
|
+
* restarted. Answering also clears that status, because HAP marks a served read successful.
|
|
679
|
+
*/
|
|
680
|
+
active.onGet(() => active.value ?? hap.Characteristic.HomeKitCameraActive.ON);
|
|
681
|
+
active.onSet(async (value, _context, connection) => {
|
|
682
|
+
const enable = value === hap.Characteristic.HomeKitCameraActive.ON;
|
|
683
|
+
if (!operable || connection === undefined || enablement() === enable) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
await issue('camera', 'enabled', () => camera.setEnabled(enable), () => undefined);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
const enabled = enablement();
|
|
691
|
+
if (enabled !== undefined) {
|
|
692
|
+
active.updateValue(enabled ? hap.Characteristic.HomeKitCameraActive.ON : hap.Characteristic.HomeKitCameraActive.OFF);
|
|
693
|
+
}
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Presents night vision as the one boolean HomeKit carries, over the three modes the SDK reports.
|
|
700
|
+
*
|
|
701
|
+
* `Off` is the only mode that is not night vision, so anything else reads as on. Turning it on again restores
|
|
702
|
+
* the mode this camera last reported for itself rather than a mode chosen here, because full colour night
|
|
703
|
+
* vision is not offered by every model — the SDK says so, and writing infrared blindly would silently
|
|
704
|
+
* downgrade a camera whose owner had chosen colour. That mode is retained on the accessory, so it survives a
|
|
705
|
+
* reconciliation and a restart: an attachment-local memory would be empty exactly when the camera currently
|
|
706
|
+
* reads off, which is when the restore matters. A camera whose lit mode has never been observed at all is
|
|
707
|
+
* turned on to infrared, which is the mode the SDK reports for every family and the one it says a model may
|
|
708
|
+
* offer without offering colour.
|
|
709
|
+
*
|
|
710
|
+
* A reading outside the modes the SDK declares is refused rather than projected, because "not off" would
|
|
711
|
+
* otherwise make any unexpected number read as night vision and be written back as a mode.
|
|
712
|
+
*/
|
|
713
|
+
function nightVisionPresentation(context, service, { camera, issue, readNumber, }) {
|
|
714
|
+
const { hap } = context;
|
|
715
|
+
const retained = (context.accessory.context ?? {});
|
|
716
|
+
context.accessory.context = retained;
|
|
717
|
+
const stored = retained.homebridgeEufyCameraNightVision;
|
|
718
|
+
let lit = stored?.version === 1 && NIGHT_VISION_MODES.has(stored.mode) && stored.mode !== NightVision.Off
|
|
719
|
+
? stored.mode
|
|
720
|
+
: undefined;
|
|
721
|
+
const mode = () => {
|
|
722
|
+
const reading = readNumber('camera', 'nightVision', () => camera.nightVision);
|
|
723
|
+
if (!NIGHT_VISION_MODES.has(reading)) {
|
|
724
|
+
context.diagnose({
|
|
725
|
+
code: INVALID_OBSERVATION_CONDITION,
|
|
726
|
+
capability: 'camera',
|
|
727
|
+
member: 'nightVision',
|
|
728
|
+
active: true,
|
|
729
|
+
reason: 'malformed',
|
|
730
|
+
});
|
|
731
|
+
throw new hap.HapStatusError(hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
732
|
+
}
|
|
733
|
+
if (reading !== NightVision.Off && lit !== reading) {
|
|
734
|
+
lit = reading;
|
|
735
|
+
retained.homebridgeEufyCameraNightVision = { version: 1, mode: reading };
|
|
736
|
+
context.persist();
|
|
737
|
+
}
|
|
738
|
+
return reading;
|
|
739
|
+
};
|
|
740
|
+
const characteristic = service.getCharacteristic(hap.Characteristic.NightVision);
|
|
741
|
+
characteristic.onGet(() => mode() !== NightVision.Off);
|
|
742
|
+
characteristic.onSet((value) => {
|
|
743
|
+
if (typeof value !== 'boolean') {
|
|
744
|
+
throw new hap.HapStatusError(hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
745
|
+
}
|
|
746
|
+
const requested = value ? (lit ?? NightVision.Infrared) : NightVision.Off;
|
|
747
|
+
return issue('camera', 'nightVision', () => camera.setNightVision(requested), () => {
|
|
748
|
+
try {
|
|
749
|
+
characteristic.updateValue(mode() !== NightVision.Off);
|
|
750
|
+
}
|
|
751
|
+
catch { }
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/** Whether this camera reports its night-vision mode and accepts a change to it. */
|
|
756
|
+
function presentsNightVision(context, camera) {
|
|
757
|
+
return (satisfiesMemberRequirements(context.evidence, [CAMERA_NIGHT_VISION_READ, CAMERA_NIGHT_VISION_WRITE]) &&
|
|
758
|
+
typeof camera.setNightVision === 'function' &&
|
|
759
|
+
!untrusted(camera, 'nightVision'));
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Whether this camera's indicator LED may be presented and operated: the SDK reports its state as an
|
|
763
|
+
* exactly evidenced boolean, accepts a write for it, and installed the setter on this device. A camera that
|
|
764
|
+
* offers the setter without reporting the state publishes nothing, because a control HomeKit cannot read is
|
|
765
|
+
* a control it would answer with a guess.
|
|
766
|
+
*/
|
|
767
|
+
function indicatesStatusLed(context, camera) {
|
|
768
|
+
return (satisfiesMemberRequirements(context.evidence, [CAMERA_STATUS_LED_READ, CAMERA_STATUS_LED_WRITE]) &&
|
|
769
|
+
typeof camera.setStatusLed === 'function' &&
|
|
770
|
+
!untrusted(camera, 'statusLed'));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* The operating mode service this accessory already carries, without creating one.
|
|
774
|
+
*
|
|
775
|
+
* Exactly one may exist: the recording controller's own where HomeKit Secure Video created it, this
|
|
776
|
+
* bundle's own under its stable key, or one the accessory restored from a cached run whose recording is no
|
|
777
|
+
* longer configured, which carries no subtype and would otherwise make a second service look absent.
|
|
778
|
+
*/
|
|
779
|
+
function existingOperatingMode(context, controller) {
|
|
780
|
+
const { accessory, hap } = context;
|
|
781
|
+
return (controller.recordingManagement?.operatingModeService ??
|
|
782
|
+
accessory.getServiceById(hap.Service.CameraOperatingMode, CAMERA_OPERATING_MODE_SERVICE_KEY) ??
|
|
783
|
+
accessory.services.find((service) => service.UUID === hap.Service.CameraOperatingMode.UUID));
|
|
784
|
+
}
|
|
785
|
+
/** That service, adding this bundle's own when the accessory carries none. */
|
|
786
|
+
function operatingModeService(context, controller) {
|
|
787
|
+
return existingOperatingMode(context, controller) ?? publishedOperatingMode(context);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Withdraws one state this plugin published, because a stale published value is a claim about a camera
|
|
791
|
+
* nothing is checking any more. Only the characteristic is withdrawn here: a controller-owned service is
|
|
792
|
+
* not this bundle's to remove, and a service this bundle owns is removed by its caller once nothing at all
|
|
793
|
+
* is left to publish on it.
|
|
794
|
+
*/
|
|
795
|
+
function withdrawCharacteristic(context, controller, attached) {
|
|
796
|
+
const service = existingOperatingMode(context, controller);
|
|
797
|
+
const characteristic = context.hap.Characteristic[attached];
|
|
798
|
+
if (service?.testCharacteristic(characteristic)) {
|
|
799
|
+
service.removeCharacteristic(service.getCharacteristic(characteristic));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* This bundle's own Camera Operating Mode service, seeded with the two states HomeKit requires it to carry.
|
|
804
|
+
*
|
|
805
|
+
* Both are seeded active because this camera does stream and does answer snapshot requests, and the
|
|
806
|
+
* recording controller seeds its own service the same way. Neither is driven from a device observation:
|
|
807
|
+
* HomeKit owns them, and on a controller-owned service HAP itself gates streams and snapshots on the
|
|
808
|
+
* HomeKit-active state.
|
|
809
|
+
*/
|
|
810
|
+
function publishedOperatingMode(context) {
|
|
811
|
+
const { accessory, hap } = context;
|
|
812
|
+
const service = accessory.addService(hap.Service.CameraOperatingMode, accessory.displayName, CAMERA_OPERATING_MODE_SERVICE_KEY);
|
|
813
|
+
service.setCharacteristic(hap.Characteristic.HomeKitCameraActive, hap.Characteristic.HomeKitCameraActive.ON);
|
|
814
|
+
service.setCharacteristic(hap.Characteristic.EventSnapshotsActive, hap.Characteristic.EventSnapshotsActive.ENABLE);
|
|
815
|
+
return service;
|
|
816
|
+
}
|
|
817
|
+
/** Withdraws this bundle's own operating mode service, leaving a controller-owned one to its owner. */
|
|
818
|
+
function removeOperatingMode(context) {
|
|
819
|
+
const service = context.accessory.getServiceById(context.hap.Service.CameraOperatingMode, CAMERA_OPERATING_MODE_SERVICE_KEY);
|
|
820
|
+
if (service) {
|
|
821
|
+
context.accessory.removeService(service);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** Reduces only a well-formed, correctly attributed SDK observation to snapshot presentation state. */
|
|
825
|
+
function availabilityObservation(serial, read) {
|
|
826
|
+
return () => {
|
|
827
|
+
try {
|
|
828
|
+
const observation = read?.();
|
|
829
|
+
if (!observation ||
|
|
830
|
+
observation.entity?.kind !== 'device' ||
|
|
831
|
+
observation.entity.sn !== serial ||
|
|
832
|
+
observation.scope !== 'device' ||
|
|
833
|
+
observation.source?.transport !== 'smqtt' ||
|
|
834
|
+
observation.source.signal !== 'state-info' ||
|
|
835
|
+
(observation.availability !== 'available' && observation.availability !== 'unavailable') ||
|
|
836
|
+
!Number.isFinite(observation.receivedAt) ||
|
|
837
|
+
(observation.observedAt !== undefined && !Number.isFinite(observation.observedAt)) ||
|
|
838
|
+
(observation.sequence !== undefined && !Number.isFinite(observation.sequence))) {
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
return observation.availability;
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* What an attachment given no budget counts against: nothing, because nothing was declared.
|
|
850
|
+
*
|
|
851
|
+
* Normalising the absent budget to one that admits everything keeps a single encoding of "no ceiling". Reading
|
|
852
|
+
* the absence at each decision instead would make one `undefined` mean both "nothing was declared" and "the
|
|
853
|
+
* host has no room", which are opposite answers.
|
|
854
|
+
*/
|
|
855
|
+
const UNBOUNDED_MEDIA = { claim: () => ({ release: () => undefined }) };
|
|
856
|
+
/** Owns HomeKit camera negotiation while delegating source adaptation to the media domain. */
|
|
857
|
+
class LiveCameraDelegate {
|
|
858
|
+
binding;
|
|
859
|
+
hap;
|
|
860
|
+
controller;
|
|
861
|
+
/** Assigned once the accessory has a service to present on, which is only true after the controller is. */
|
|
862
|
+
sessions = new Map();
|
|
863
|
+
prepareGenerations = new Map();
|
|
864
|
+
/**
|
|
865
|
+
* The share of host capacity each HomeKit session holds, keyed by the identifier HomeKit gave it.
|
|
866
|
+
*
|
|
867
|
+
* One session is one share however often its endpoints are negotiated. A controller may answer
|
|
868
|
+
* `SetupEndpoints` again for a session it already has, and the replacement is only recorded once the new
|
|
869
|
+
* preparation succeeds, so a share held per prepared session would have that controller competing with
|
|
870
|
+
* itself and refuse it at a ceiling it is already inside.
|
|
871
|
+
*/
|
|
872
|
+
claims = new Map();
|
|
873
|
+
/** Station holds taken for the life of a session, so opportunistic work elsewhere on it stands aside. */
|
|
874
|
+
stationHolds = new Map();
|
|
875
|
+
/** Started requests still waiting for an outcome, so one that never reaches either can say so. */
|
|
876
|
+
unaccounted = new Map();
|
|
877
|
+
snapshotScope;
|
|
878
|
+
acceptingSessions = true;
|
|
879
|
+
refused = false;
|
|
880
|
+
capacityRefused = false;
|
|
881
|
+
supervision;
|
|
882
|
+
constructor(serial, binding, hap) {
|
|
883
|
+
this.binding = binding;
|
|
884
|
+
this.hap = hap;
|
|
885
|
+
this.snapshotScope = {
|
|
886
|
+
identity: {},
|
|
887
|
+
serial,
|
|
888
|
+
...(binding.stationSn ? { stationSn: binding.stationSn } : {}),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
update(binding) {
|
|
892
|
+
this.binding = binding;
|
|
893
|
+
this.acceptingSessions = true;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Answers a snapshot request from the camera's own acquisition policy, passing it the admitted enablement
|
|
897
|
+
* observation because a camera that is off is presented rather than photographed.
|
|
898
|
+
*
|
|
899
|
+
* A request no camera image could answer latches exactly one bounded reason naming the acquisition that
|
|
900
|
+
* left it unanswered, and keeps it until a later real image withdraws it, so an intermittently failing
|
|
901
|
+
* camera is distinguishable in the log from one that is permanently unequipped: the intermittent one
|
|
902
|
+
* latches and clears repeatedly, while the unequipped one latches once. The reason is latched whether a
|
|
903
|
+
* placeholder was substituted or the request failed outright, and a live refresh that fails afterwards
|
|
904
|
+
* supersedes it while the camera still has nothing to show.
|
|
905
|
+
*
|
|
906
|
+
* A disabled camera latches nothing: its image is the intended presentation, and live view already reports
|
|
907
|
+
* why it cannot be watched. A camera with no snapshot adaptation at all latches the missing adaptation
|
|
908
|
+
* itself, because no acquisition was reached to report anything about.
|
|
909
|
+
*/
|
|
910
|
+
handleSnapshotRequest(_request, callback) {
|
|
911
|
+
const snapshotMedia = this.binding.snapshotMedia;
|
|
912
|
+
if (!snapshotMedia) {
|
|
913
|
+
this.binding.reportSnapshot('adapter-missing');
|
|
914
|
+
callback(new Error('camera snapshot adaptation is unavailable'));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
let unanswered = false;
|
|
918
|
+
const enabled = this.binding.enablement();
|
|
919
|
+
const availability = this.binding.availability();
|
|
920
|
+
void snapshotMedia
|
|
921
|
+
.acquire(this.snapshotScope, this.binding.source, this.binding.snapshotMode, {
|
|
922
|
+
...(enabled === undefined ? {} : { enabled }),
|
|
923
|
+
...(availability === undefined ? {} : { availability }),
|
|
924
|
+
onUnavailable: (failure) => {
|
|
925
|
+
unanswered = true;
|
|
926
|
+
this.binding.reportSnapshot(failure);
|
|
927
|
+
},
|
|
928
|
+
})
|
|
929
|
+
.then((buffer) => {
|
|
930
|
+
if (!unanswered) {
|
|
931
|
+
this.binding.reportSnapshot();
|
|
932
|
+
}
|
|
933
|
+
callback(undefined, buffer);
|
|
934
|
+
}, (error) => callback(error instanceof Error ? error : new Error('camera snapshot failed')));
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Answers `SetupEndpoints` with reservations whose lifetime is the controller's HAP connection. A
|
|
938
|
+
* prepared session holds one UDP port for video, a second when audio is negotiated, and no SDK handle,
|
|
939
|
+
* process, or device session; HomeKit bounds it by that connection rather than by a timer, so a
|
|
940
|
+
* controller that negotiates and then waits keeps a valid answer instead of being invalidated by a
|
|
941
|
+
* plugin deadline.
|
|
942
|
+
*
|
|
943
|
+
* A camera an admitted observation reports as disabled is refused here, which is the only refusal point
|
|
944
|
+
* HAP offers, so no port, handle, or process is opened for it at all.
|
|
945
|
+
*/
|
|
946
|
+
prepareStream(request, callback) {
|
|
947
|
+
if (this.refuseWhenDisabled('disabled')) {
|
|
948
|
+
this.traceRefusal('disabled');
|
|
949
|
+
callback(new Error('camera is disabled'));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (!this.claimCapacity(request.sessionID)) {
|
|
953
|
+
this.traceRefusal('at-capacity');
|
|
954
|
+
callback(new Error('the declared concurrent media limit is reached'));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const generation = Symbol('camera-stream-prepare');
|
|
958
|
+
this.prepareGenerations.set(request.sessionID, generation);
|
|
959
|
+
void this.prepare(request).then(({ response, session }) => {
|
|
960
|
+
if (!this.acceptingSessions || this.prepareGenerations.get(request.sessionID) !== generation) {
|
|
961
|
+
session.prepared.stop();
|
|
962
|
+
this.releaseUnheldClaim(request.sessionID);
|
|
963
|
+
this.traceRefusal('cancelled');
|
|
964
|
+
callback(new Error('live media preparation was cancelled'));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const superseded = this.sessions.get(request.sessionID);
|
|
968
|
+
this.sessions.delete(request.sessionID);
|
|
969
|
+
this.prepareGenerations.delete(request.sessionID);
|
|
970
|
+
superseded?.prepared.stop();
|
|
971
|
+
this.sessions.set(request.sessionID, session);
|
|
972
|
+
this.admit();
|
|
973
|
+
this.withdrawCapacityRefusal();
|
|
974
|
+
callback(undefined, response);
|
|
975
|
+
}, (error) => {
|
|
976
|
+
if (this.prepareGenerations.get(request.sessionID) === generation) {
|
|
977
|
+
this.prepareGenerations.delete(request.sessionID);
|
|
978
|
+
}
|
|
979
|
+
this.releaseUnheldClaim(request.sessionID);
|
|
980
|
+
this.traceRefusal('prepare-failed');
|
|
981
|
+
callback(error instanceof Error ? error : new Error('failed to prepare live media'));
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Holds this session's share of the declared media ceiling, reporting one bounded reason where the host has
|
|
986
|
+
* no room for it.
|
|
987
|
+
*
|
|
988
|
+
* The share is taken before any await, so two cameras asked at once cannot both pass the same check. It is
|
|
989
|
+
* held for the whole session lifetime rather than only while media flows, because a prepared session holds a
|
|
990
|
+
* reserved port and HomeKit bounds it by its own connection; releasing it earlier would let a controller
|
|
991
|
+
* that prepares and waits sit outside the count it was admitted under.
|
|
992
|
+
*/
|
|
993
|
+
claimCapacity(sessionID) {
|
|
994
|
+
if (this.claims.has(sessionID)) {
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
const claim = (this.binding.budget ?? UNBOUNDED_MEDIA).claim();
|
|
998
|
+
if (!claim) {
|
|
999
|
+
this.capacityRefused = true;
|
|
1000
|
+
this.binding.reportCapacity('at-capacity');
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
this.claims.set(sessionID, claim);
|
|
1004
|
+
if (this.binding.stationSn && this.binding.stations && !this.stationHolds.has(sessionID)) {
|
|
1005
|
+
this.stationHolds.set(sessionID, this.binding.stations.hold(this.binding.stationSn, this.binding.serial, 'live'));
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
/** Give back this session's station hold. Idempotent, and safe for a session that never took one. */
|
|
1010
|
+
releaseStationHold(sessionID) {
|
|
1011
|
+
this.stationHolds.get(sessionID)?.();
|
|
1012
|
+
this.stationHolds.delete(sessionID);
|
|
1013
|
+
}
|
|
1014
|
+
/** Gives back a share taken for a preparation that never became a session, and only then. */
|
|
1015
|
+
releaseUnheldClaim(sessionID) {
|
|
1016
|
+
if (this.sessions.has(sessionID)) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.claims.get(sessionID)?.release();
|
|
1020
|
+
this.claims.delete(sessionID);
|
|
1021
|
+
this.releaseStationHold(sessionID);
|
|
1022
|
+
}
|
|
1023
|
+
/** Withdraws a latched capacity refusal once a session is admitted again, and only then. */
|
|
1024
|
+
withdrawCapacityRefusal() {
|
|
1025
|
+
if (!this.capacityRefused) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.capacityRefused = false;
|
|
1029
|
+
this.binding.reportCapacity();
|
|
1030
|
+
}
|
|
1031
|
+
async prepare(request) {
|
|
1032
|
+
const videoSsrc = this.hap.CameraController.generateSynchronisationSource();
|
|
1033
|
+
const audioSsrc = this.binding.audioEnabled ? this.hap.CameraController.generateSynchronisationSource() : undefined;
|
|
1034
|
+
let session;
|
|
1035
|
+
const prepared = await this.binding.media.prepare({
|
|
1036
|
+
addressVersion: request.addressVersion,
|
|
1037
|
+
targetAddress: request.targetAddress,
|
|
1038
|
+
video: mediaTarget(request.video),
|
|
1039
|
+
...(this.binding.audioEnabled ? { audio: mediaTarget(request.audio) } : {}),
|
|
1040
|
+
onVideoFailure: () => {
|
|
1041
|
+
this.controller?.forceStopStreamingSession(request.sessionID);
|
|
1042
|
+
this.release(request.sessionID);
|
|
1043
|
+
},
|
|
1044
|
+
onSessionOutcome: (outcome) => {
|
|
1045
|
+
const switchedOff = outcome.outcome === 'failed' && outcome.reason === 'source-audio-only' && this.binding.enablement() === false;
|
|
1046
|
+
if (switchedOff) {
|
|
1047
|
+
this.refused = true;
|
|
1048
|
+
}
|
|
1049
|
+
this.settleOutcome(request.sessionID);
|
|
1050
|
+
this.binding.reportSession(outcome, switchedOff);
|
|
1051
|
+
if (outcome.outcome === 'streaming' &&
|
|
1052
|
+
session?.source &&
|
|
1053
|
+
session.snapshotMedia?.captureFromWarmLive &&
|
|
1054
|
+
!session.capturedFallback) {
|
|
1055
|
+
session.capturedFallback = true;
|
|
1056
|
+
void session.snapshotMedia.captureFromWarmLive(this.snapshotScope, session.source).catch(() => undefined);
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
onSessionReleased: this.binding.reportRelease,
|
|
1060
|
+
onTalkbackOutcome: (outcome) => this.binding.reportTalkback(outcome),
|
|
1061
|
+
});
|
|
1062
|
+
session = {
|
|
1063
|
+
prepared,
|
|
1064
|
+
videoSsrc,
|
|
1065
|
+
addressVersion: request.addressVersion,
|
|
1066
|
+
...(audioSsrc === undefined ? {} : { audioSsrc }),
|
|
1067
|
+
...(this.binding.snapshotMedia ? { snapshotMedia: this.binding.snapshotMedia } : {}),
|
|
1068
|
+
};
|
|
1069
|
+
return {
|
|
1070
|
+
session,
|
|
1071
|
+
response: {
|
|
1072
|
+
video: {
|
|
1073
|
+
port: prepared.videoPort,
|
|
1074
|
+
ssrc: videoSsrc,
|
|
1075
|
+
srtp_key: request.video.srtp_key,
|
|
1076
|
+
srtp_salt: request.video.srtp_salt,
|
|
1077
|
+
},
|
|
1078
|
+
...(prepared.audioPort === undefined || audioSsrc === undefined
|
|
1079
|
+
? {}
|
|
1080
|
+
: {
|
|
1081
|
+
audio: {
|
|
1082
|
+
port: prepared.audioPort,
|
|
1083
|
+
ssrc: audioSsrc,
|
|
1084
|
+
srtp_key: request.audio.srtp_key,
|
|
1085
|
+
srtp_salt: request.audio.srtp_salt,
|
|
1086
|
+
},
|
|
1087
|
+
}),
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
handleStreamRequest(request, callback) {
|
|
1092
|
+
if (request.type === 'stop') {
|
|
1093
|
+
this.release(request.sessionID);
|
|
1094
|
+
callback();
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const session = this.sessions.get(request.sessionID);
|
|
1098
|
+
if (!session) {
|
|
1099
|
+
callback(new Error('live media session was not prepared'));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (request.type === 'reconfigure') {
|
|
1103
|
+
if (!session.selection) {
|
|
1104
|
+
this.release(request.sessionID);
|
|
1105
|
+
callback(new Error('live media session has not started'));
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const video = negotiatedVideo({ ...session.selection.video, ...request.video }, session.videoSsrc, this.hap);
|
|
1109
|
+
session.prepared.reconfigure(video);
|
|
1110
|
+
session.selection = { ...session.selection, video: { ...session.selection.video, ...request.video } };
|
|
1111
|
+
this.traceSelection('reconfigure', video, session.addressVersion);
|
|
1112
|
+
callback();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (this.refuseWhenDisabled('disabled')) {
|
|
1116
|
+
this.release(request.sessionID);
|
|
1117
|
+
callback(new Error('camera is disabled'));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
session.selection = request;
|
|
1121
|
+
const video = negotiatedVideo(request.video, session.videoSsrc, this.hap);
|
|
1122
|
+
this.traceSelection('start', video, session.addressVersion);
|
|
1123
|
+
this.watchForOutcome(request.sessionID);
|
|
1124
|
+
const source = this.binding.source;
|
|
1125
|
+
session.source = source;
|
|
1126
|
+
this.supervise();
|
|
1127
|
+
void session.prepared
|
|
1128
|
+
.start(source, {
|
|
1129
|
+
video,
|
|
1130
|
+
...(this.binding.audioEnabled && session.audioSsrc !== undefined
|
|
1131
|
+
? {
|
|
1132
|
+
audio: {
|
|
1133
|
+
codec: 'AAC-eld',
|
|
1134
|
+
channels: request.audio.channel,
|
|
1135
|
+
sampleRate: request.audio.sample_rate === 24 /* this.hap.AudioStreamingSamplerate.KHZ_24 */ ? 24 : 16,
|
|
1136
|
+
maxBitRate: request.audio.max_bit_rate,
|
|
1137
|
+
payloadType: request.audio.pt,
|
|
1138
|
+
ssrc: session.audioSsrc,
|
|
1139
|
+
},
|
|
1140
|
+
}
|
|
1141
|
+
: {}),
|
|
1142
|
+
})
|
|
1143
|
+
.then(() => callback(), (error) => {
|
|
1144
|
+
this.release(request.sessionID);
|
|
1145
|
+
callback(error instanceof Error ? error : new Error('stream failed'));
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Report a request refused before it reached the source.
|
|
1150
|
+
*
|
|
1151
|
+
* A controller is answered instantly and badges the camera instantly, so without this the badge an operator
|
|
1152
|
+
* sees at the moment of a switch has no counterpart anywhere. `cancelled` and `at-capacity` are the two a
|
|
1153
|
+
* fast switch produces, and neither is a fault.
|
|
1154
|
+
*/
|
|
1155
|
+
traceRefusal(reason) {
|
|
1156
|
+
this.binding.reportSelection?.({ event: 'live-request-refused', reason });
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Report a started request that reached neither a streaming outcome nor a failure.
|
|
1160
|
+
*
|
|
1161
|
+
* Every request is supposed to end in one or the other, and a controller badges the camera either way. One
|
|
1162
|
+
* that ends in neither leaves the operator looking at a failure the log has no record of, which is
|
|
1163
|
+
* unfixable by anyone: measured once as a selection followed by no adaptation, no source command and no
|
|
1164
|
+
* outcome at all. This does not repair that request. It makes it visible, which is the prerequisite.
|
|
1165
|
+
*/
|
|
1166
|
+
watchForOutcome(sessionID) {
|
|
1167
|
+
this.settleOutcome(sessionID);
|
|
1168
|
+
const armed = Date.now();
|
|
1169
|
+
const timer = setTimeout(() => {
|
|
1170
|
+
this.unaccounted.delete(sessionID);
|
|
1171
|
+
this.binding.reportSelection?.({ event: 'live-request-unaccounted', afterMs: Date.now() - armed });
|
|
1172
|
+
}, UNACCOUNTED_REQUEST_MS);
|
|
1173
|
+
timer.unref?.();
|
|
1174
|
+
this.unaccounted.set(sessionID, timer);
|
|
1175
|
+
}
|
|
1176
|
+
/** A request has reached an outcome, or is gone, so it is no longer owed one. */
|
|
1177
|
+
settleOutcome(sessionID) {
|
|
1178
|
+
const timer = this.unaccounted.get(sessionID);
|
|
1179
|
+
if (timer) {
|
|
1180
|
+
clearTimeout(timer);
|
|
1181
|
+
this.unaccounted.delete(sessionID);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Records one identity-free live video selection.
|
|
1186
|
+
*
|
|
1187
|
+
* A reconfigure carries the packet size the start selection negotiated: HomeKit's reconfigure request
|
|
1188
|
+
* declares geometry, frame rate and bit rate but no MTU, so the merged selection keeps the original.
|
|
1189
|
+
*/
|
|
1190
|
+
traceSelection(operation, video, addressVersion) {
|
|
1191
|
+
this.binding.reportSelection?.({
|
|
1192
|
+
event: 'live-video-selected',
|
|
1193
|
+
operation,
|
|
1194
|
+
profile: video.profile,
|
|
1195
|
+
level: video.level,
|
|
1196
|
+
width: video.width,
|
|
1197
|
+
height: video.height,
|
|
1198
|
+
fps: video.fps,
|
|
1199
|
+
mtu: video.mtu,
|
|
1200
|
+
addressVersion,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
stop() {
|
|
1204
|
+
this.acceptingSessions = false;
|
|
1205
|
+
for (const sessionID of [...this.sessions.keys()]) {
|
|
1206
|
+
this.release(sessionID);
|
|
1207
|
+
}
|
|
1208
|
+
for (const claim of this.claims.values()) {
|
|
1209
|
+
claim.release();
|
|
1210
|
+
}
|
|
1211
|
+
this.claims.clear();
|
|
1212
|
+
for (const release of this.stationHolds.values()) {
|
|
1213
|
+
release();
|
|
1214
|
+
}
|
|
1215
|
+
this.stationHolds.clear();
|
|
1216
|
+
this.prepareGenerations.clear();
|
|
1217
|
+
this.unsupervise();
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Releases the reservations of one recorded session exactly once, whatever ended it. HomeKit ends a
|
|
1221
|
+
* session without delivering a stop request when it force-stops one or when a stream request reports an
|
|
1222
|
+
* error, so those paths release here too and no ended session is retained. A preparation cancelled
|
|
1223
|
+
* before it was recorded releases itself where it completes.
|
|
1224
|
+
*/
|
|
1225
|
+
release(sessionID) {
|
|
1226
|
+
this.settleOutcome(sessionID);
|
|
1227
|
+
this.prepareGenerations.delete(sessionID);
|
|
1228
|
+
const session = this.sessions.get(sessionID);
|
|
1229
|
+
this.sessions.delete(sessionID);
|
|
1230
|
+
session?.prepared.stop();
|
|
1231
|
+
this.claims.get(sessionID)?.release();
|
|
1232
|
+
this.claims.delete(sessionID);
|
|
1233
|
+
this.releaseStationHold(sessionID);
|
|
1234
|
+
if (this.sessions.size === 0) {
|
|
1235
|
+
this.unsupervise();
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** Reports one refusal when an admitted observation says the camera is disabled. */
|
|
1239
|
+
refuseWhenDisabled(refusal) {
|
|
1240
|
+
const enabled = this.binding.enablement();
|
|
1241
|
+
if (enabled !== false) {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
this.refused = true;
|
|
1245
|
+
this.binding.reportAdmission(refusal);
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
/** Withdraws a latched refusal once a session is admitted again, and only then. */
|
|
1249
|
+
admit() {
|
|
1250
|
+
if (!this.refused) {
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
this.refused = false;
|
|
1254
|
+
this.binding.reportAdmission();
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Supervises the sessions of a camera that is streaming. HomeKit is told the session ended, because a
|
|
1258
|
+
* force-stop does not reach this delegate, and the same single release path stops adaptation and the SDK
|
|
1259
|
+
* consumer rather than letting a disabled camera keep delivering blank frames.
|
|
1260
|
+
*/
|
|
1261
|
+
supervise() {
|
|
1262
|
+
if (this.supervision) {
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
this.supervision = setInterval(() => this.observeEnablement(), ENABLEMENT_SUPERVISION_INTERVAL_MS);
|
|
1266
|
+
this.supervision.unref?.();
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Acts on the enablement observation as it reads now, whether an announced change or the supervision tick
|
|
1270
|
+
* asked. A camera with nothing being watched needs no action here: presentation already followed the
|
|
1271
|
+
* change, and admission is decided when the next session asks.
|
|
1272
|
+
*/
|
|
1273
|
+
observeEnablement() {
|
|
1274
|
+
if (this.sessions.size === 0 || !this.refuseWhenDisabled('disabled-mid-session')) {
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
for (const sessionID of [...this.sessions.keys()]) {
|
|
1278
|
+
this.controller?.forceStopStreamingSession(sessionID);
|
|
1279
|
+
this.release(sessionID);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
unsupervise() {
|
|
1283
|
+
clearInterval(this.supervision);
|
|
1284
|
+
this.supervision = undefined;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/** The one crypto suite this controller advertises, so it is the only one a controller can have selected. */
|
|
1288
|
+
function mediaTarget(source) {
|
|
1289
|
+
return {
|
|
1290
|
+
port: source.port,
|
|
1291
|
+
srtpCryptoSuite: 'AES_CM_128_HMAC_SHA1_80',
|
|
1292
|
+
srtpKey: source.srtp_key,
|
|
1293
|
+
srtpSalt: source.srtp_salt,
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function negotiatedVideo(video, ssrc, hap) {
|
|
1297
|
+
return {
|
|
1298
|
+
width: video.width,
|
|
1299
|
+
height: video.height,
|
|
1300
|
+
fps: video.fps,
|
|
1301
|
+
maxBitRate: video.max_bit_rate,
|
|
1302
|
+
profile: h264Profile(video.profile, hap),
|
|
1303
|
+
level: h264Level(video.level, hap),
|
|
1304
|
+
payloadType: video.pt,
|
|
1305
|
+
ssrc,
|
|
1306
|
+
mtu: video.mtu,
|
|
1307
|
+
rtcpInterval: video.rtcp_interval,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
function h264Profile(profile, hap) {
|
|
1311
|
+
return profile === 2 /* hap.H264Profile.HIGH */ ? 'high' : profile === 1 /* hap.H264Profile.MAIN */ ? 'main' : 'baseline';
|
|
1312
|
+
}
|
|
1313
|
+
function h264Level(level, hap) {
|
|
1314
|
+
return level === 2 /* hap.H264Level.LEVEL4_0 */ ? '4.0' : level === 1 /* hap.H264Level.LEVEL3_2 */ ? '3.2' : '3.1';
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Owns HomeKit Secure Video negotiation while delegating fragment adaptation to the media domain.
|
|
1318
|
+
*
|
|
1319
|
+
* Nothing is held between recordings and no source is ever opened to build pre-event media. A camera
|
|
1320
|
+
* eligible to retain a window has it configured on the shared source its live view and its recordings both
|
|
1321
|
+
* use, so a recording drains whatever that source happened to be carrying and begins at its trigger
|
|
1322
|
+
* otherwise. HomeKit persists whether recording is active and which configuration was selected, and this
|
|
1323
|
+
* delegate keeps both only so that a recording request can be answered.
|
|
1324
|
+
*/
|
|
1325
|
+
class RecordingCameraDelegate {
|
|
1326
|
+
binding;
|
|
1327
|
+
hap;
|
|
1328
|
+
recordingManagement;
|
|
1329
|
+
configuration;
|
|
1330
|
+
/** Every recording being served, held under its stream for as long as its consumer has not closed it. */
|
|
1331
|
+
streams = new Map();
|
|
1332
|
+
refused = false;
|
|
1333
|
+
constructor(binding, hap) {
|
|
1334
|
+
this.binding = binding;
|
|
1335
|
+
this.hap = hap;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Rebinds this delegate to the current attachment, or withdraws it.
|
|
1339
|
+
*
|
|
1340
|
+
* A reconciliation that no longer admits recording leaves the HomeKit services in place, because a
|
|
1341
|
+
* controller's services are fixed when it is configured, so withdrawal is expressed by refusing
|
|
1342
|
+
* recordings rather than by continuing to serve them from a source this accessory no longer has. Any
|
|
1343
|
+
* recording still running is stopped, since its source went with the binding that opened it.
|
|
1344
|
+
*/
|
|
1345
|
+
update(binding) {
|
|
1346
|
+
this.binding = binding;
|
|
1347
|
+
if (!binding) {
|
|
1348
|
+
this.stop();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* HomeKit's recording-active state needs nothing of this delegate. It exists so a camera can start or
|
|
1353
|
+
* stop maintaining pre-event media, and no camera here maintains any on HomeKit's word: an eligible
|
|
1354
|
+
* camera's window is configured on its shared source and fills only while something else is already
|
|
1355
|
+
* streaming, so there is nothing to start or stop.
|
|
1356
|
+
*/
|
|
1357
|
+
updateRecordingActive() { }
|
|
1358
|
+
updateRecordingConfiguration(configuration) {
|
|
1359
|
+
this.configuration = configuration;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Streams one recording as the units its adaptation produces.
|
|
1363
|
+
*
|
|
1364
|
+
* The configuration is read once, so a selection HomeKit changes mid-recording applies to the next
|
|
1365
|
+
* recording rather than to this one. Cancellation arrives three ways — an aborted signal, a closed
|
|
1366
|
+
* stream, or a consumer that stops iterating — and every one of them reaches the same single stop, so
|
|
1367
|
+
* the SDK handle and the adaptation are released once however the recording ended.
|
|
1368
|
+
*
|
|
1369
|
+
* A recording that ends while its consumer still holds the stream open ended without a unit marked
|
|
1370
|
+
* last, and is cancelled rather than left for that consumer's force-close, which holds the camera's one
|
|
1371
|
+
* recording stream and refuses every trigger until it elapses.
|
|
1372
|
+
*/
|
|
1373
|
+
async *handleRecordingStreamRequest(streamId, signal) {
|
|
1374
|
+
const binding = this.binding;
|
|
1375
|
+
const configuration = this.configuration;
|
|
1376
|
+
if (!binding || !configuration || !binding.source.recordFragments) {
|
|
1377
|
+
throw new this.hap.HDSProtocolError(9 /* this.hap.HDSProtocolSpecificErrorReason.INVALID_CONFIGURATION */);
|
|
1378
|
+
}
|
|
1379
|
+
if (binding.enablement() === false) {
|
|
1380
|
+
this.refused = true;
|
|
1381
|
+
binding.reportAdmission('disabled');
|
|
1382
|
+
throw new this.hap.HDSProtocolError(1 /* this.hap.HDSProtocolSpecificErrorReason.NOT_ALLOWED */);
|
|
1383
|
+
}
|
|
1384
|
+
if (this.refused) {
|
|
1385
|
+
this.refused = false;
|
|
1386
|
+
binding.reportAdmission();
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* A station already serving another of its cameras to something this recording does not outrank cannot
|
|
1390
|
+
* serve this one, and the SDK refuses it. Reporting that here spares a round trip whose only outcome is
|
|
1391
|
+
* the refusal, and states it as the bounded reason rather than letting it arrive as a source error.
|
|
1392
|
+
*/
|
|
1393
|
+
if (binding.stationSn &&
|
|
1394
|
+
binding.stations &&
|
|
1395
|
+
!binding.stations.admits(binding.stationSn, binding.serial, 'recording')) {
|
|
1396
|
+
binding.reportRecording({ outcome: 'failed', reason: 'station-busy' });
|
|
1397
|
+
throw new this.hap.HDSProtocolError(1 /* this.hap.HDSProtocolSpecificErrorReason.NOT_ALLOWED */);
|
|
1398
|
+
}
|
|
1399
|
+
const recording = binding.media.record(binding.source, this.negotiated(binding, configuration), {
|
|
1400
|
+
onOutcome: (outcome) => binding.reportRecording(outcome),
|
|
1401
|
+
});
|
|
1402
|
+
this.streams.set(streamId, recording);
|
|
1403
|
+
const abort = () => this.closeRecordingStream(streamId);
|
|
1404
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1405
|
+
/**
|
|
1406
|
+
* A recording holds the station its camera belongs to, so a still elsewhere on that base stands aside and a
|
|
1407
|
+
* live view opened on a sibling takes it. Yielding stops this recording rather than letting it run on a
|
|
1408
|
+
* station that has been re-tasked, because a recording of a camera the base is no longer serving produces
|
|
1409
|
+
* nothing and would keep asking for the channel the viewer needs.
|
|
1410
|
+
*
|
|
1411
|
+
* Work on this camera never yields to work on this camera: they share one pull.
|
|
1412
|
+
*/
|
|
1413
|
+
const releaseStation = binding.stationSn && binding.stations
|
|
1414
|
+
? binding.stations.hold(binding.stationSn, binding.serial, 'recording', () => recording.stop())
|
|
1415
|
+
: undefined;
|
|
1416
|
+
try {
|
|
1417
|
+
for await (const fragment of recording) {
|
|
1418
|
+
yield { data: fragment.data, isLast: fragment.last };
|
|
1419
|
+
if (fragment.last) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (this.streams.get(streamId) === recording) {
|
|
1424
|
+
throw new this.hap.HDSProtocolError(3 /* this.hap.HDSProtocolSpecificErrorReason.CANCELLED */);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
finally {
|
|
1428
|
+
signal?.removeEventListener('abort', abort);
|
|
1429
|
+
recording.stop();
|
|
1430
|
+
releaseStation?.();
|
|
1431
|
+
this.streams.delete(streamId);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
acknowledgeStream(streamId) {
|
|
1435
|
+
this.closeRecordingStream(streamId);
|
|
1436
|
+
}
|
|
1437
|
+
closeRecordingStream(streamId) {
|
|
1438
|
+
this.streams.get(streamId)?.stop();
|
|
1439
|
+
this.streams.delete(streamId);
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Ends every recording this delegate is serving, because the binding that opened them is gone.
|
|
1443
|
+
*
|
|
1444
|
+
* Each recording is stopped without deregistering its stream, so its ending reaches its controller as
|
|
1445
|
+
* the cancellation it is rather than as a close that controller asked for.
|
|
1446
|
+
*/
|
|
1447
|
+
stop() {
|
|
1448
|
+
for (const recording of this.streams.values()) {
|
|
1449
|
+
recording.stop();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Translates one selected HomeKit recording configuration into the media domain's vocabulary.
|
|
1454
|
+
*
|
|
1455
|
+
* Audio is withheld whenever HomeKit's own recording-audio state is off, whenever the user turned this
|
|
1456
|
+
* camera's audio off, and whenever a controller selected a codec or sample rate this camera never
|
|
1457
|
+
* advertised, because a recording with no audio track is the truthful answer in every one of those cases
|
|
1458
|
+
* and a substituted codec would not be.
|
|
1459
|
+
*
|
|
1460
|
+
* The pre-event window a controller selected is asked for up to what this camera retains, so a controller
|
|
1461
|
+
* asking for less asks for less and one asking for more than was advertised gets the advertised window.
|
|
1462
|
+
* The selection is a request rather than a bound, because how much media a source has retained is the
|
|
1463
|
+
* source's own answer.
|
|
1464
|
+
*/
|
|
1465
|
+
negotiated(binding, configuration) {
|
|
1466
|
+
const [width, height, fps] = configuration.videoCodec.resolution;
|
|
1467
|
+
const audio = this.recordedAudio(binding, configuration);
|
|
1468
|
+
return {
|
|
1469
|
+
width,
|
|
1470
|
+
height,
|
|
1471
|
+
fps,
|
|
1472
|
+
maxBitRate: configuration.videoCodec.parameters.bitRate,
|
|
1473
|
+
profile: h264Profile(configuration.videoCodec.parameters.profile, this.hap),
|
|
1474
|
+
level: h264Level(configuration.videoCodec.parameters.level, this.hap),
|
|
1475
|
+
iFrameIntervalMs: configuration.videoCodec.parameters.iFrameInterval,
|
|
1476
|
+
fragmentLengthMs: configuration.mediaContainerConfiguration.fragmentLength,
|
|
1477
|
+
prebufferLengthMs: Math.max(0, Math.min(configuration.prebufferLength, binding.prebufferLengthMs)),
|
|
1478
|
+
...(audio ? { audio } : {}),
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
recordedAudio(binding, configuration) {
|
|
1482
|
+
if (!binding.audioEnabled || !this.recordingAudioActive()) {
|
|
1483
|
+
return undefined;
|
|
1484
|
+
}
|
|
1485
|
+
const codec = configuration.audioCodec.type === 1 /* this.hap.AudioRecordingCodecType.AAC_ELD */
|
|
1486
|
+
? 'AAC-eld'
|
|
1487
|
+
: configuration.audioCodec.type === 0 /* this.hap.AudioRecordingCodecType.AAC_LC */
|
|
1488
|
+
? 'AAC-lc'
|
|
1489
|
+
: undefined;
|
|
1490
|
+
const rates = RECORDING_SAMPLE_RATES(this.hap);
|
|
1491
|
+
const sampleRate = RECORDING_AUDIO_SAMPLE_RATES.find((rate) => rates[rate] === configuration.audioCodec.samplerate);
|
|
1492
|
+
if (!codec || sampleRate === undefined) {
|
|
1493
|
+
return undefined;
|
|
1494
|
+
}
|
|
1495
|
+
return {
|
|
1496
|
+
codec,
|
|
1497
|
+
channels: configuration.audioCodec.audioChannels ?? 1,
|
|
1498
|
+
sampleRate,
|
|
1499
|
+
maxBitRate: configuration.audioCodec.bitrate,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Whether HomeKit currently wants recorded audio. The characteristic lives on the recording management
|
|
1504
|
+
* service rather than the operating mode service, and an accessory starts with it off, so a recording
|
|
1505
|
+
* carries no audio until a controller asks for it.
|
|
1506
|
+
*/
|
|
1507
|
+
recordingAudioActive() {
|
|
1508
|
+
const service = this.recordingManagement?.recordingManagementService;
|
|
1509
|
+
if (!service) {
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
return Boolean(service.getCharacteristic(this.hap.Characteristic.RecordingAudioActive).value);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
//# sourceMappingURL=camera-streaming.js.map
|