@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,1003 @@
|
|
|
1
|
+
import { LiveStreamStartError } from '@mega-yfue/eufy-sdk';
|
|
2
|
+
import { createSocket } from 'node:dgram';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
/** How long a deliberately stopped adaptation process is given to exit before it is killed. */
|
|
6
|
+
export const PROCESS_STOP_GRACE_MS = 2_000;
|
|
7
|
+
/**
|
|
8
|
+
* Whether an adaptation opened for `running` can code what the source announced as `announced`.
|
|
9
|
+
*
|
|
10
|
+
* Only the codec decides it. An adaptation's input format is fixed at spawn, so an H.265 access unit fed to
|
|
11
|
+
* a process opened for H.264 does not parse; a changed geometry is absorbed, because the decoder
|
|
12
|
+
* reinitialises on the new parameter sets and the scale filter follows it. Measured through this plugin's
|
|
13
|
+
* bundled FFmpeg on its own argument list: 60 of 60 frames across 1280x720 to 640x360, 60 of 60 in reverse,
|
|
14
|
+
* 90 of 90 across 640 to 1280 to 640, no warnings; and a codec change decoding only the frames before it.
|
|
15
|
+
*
|
|
16
|
+
* The geometry never reached the argument list either — the output geometry is HomeKit's selection — so
|
|
17
|
+
* rebuilding on it spawned a byte-identical command, discarded the frames in flight, and waited for the
|
|
18
|
+
* next keyframe. A camera ramps its ladder during the first seconds of a session, four rungs inside 3.9 s
|
|
19
|
+
* on a mains-powered camera measured on the fleet, so all of that was paid before any video reached the
|
|
20
|
+
* controller.
|
|
21
|
+
*/
|
|
22
|
+
function canCode(running, announced) {
|
|
23
|
+
return running.codec === announced.codec;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* How many times one live session replaces its adaptation because the source's input changed, before it
|
|
27
|
+
* fails the session instead.
|
|
28
|
+
*
|
|
29
|
+
* A replacement costs a process, FFmpeg's stream analysis, the encoder's state, and the wait for another
|
|
30
|
+
* source keyframe, withholding every access unit in between. A source alternating between two inputs pays
|
|
31
|
+
* that at every keyframe and produces nothing watchable, so this bound sits above the changes a source
|
|
32
|
+
* settling on one input makes and below an alternation. Only a changed codec reaches it: {@link canCode}
|
|
33
|
+
* absorbs a changed geometry.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_SOURCE_INPUT_REPLACEMENTS = 3;
|
|
36
|
+
const INITIAL_RTCP_GRACE_MS = 15_000;
|
|
37
|
+
const SOURCE_ACQUISITION_DEADLINE_MS = 10_000;
|
|
38
|
+
const RETURN_AUDIO_BIND_GRACE_MS = 250;
|
|
39
|
+
/**
|
|
40
|
+
* Backstop for a started media session that never produces adapted output. The SDK source owns the
|
|
41
|
+
* warm-up window, retries the start inside it, and fails its consumers with a typed `error` event, which
|
|
42
|
+
* is the primary failure signal for both live and recording sessions; that window is an SDK-internal
|
|
43
|
+
* default the plugin cannot read, so this bound sits strictly above it and only catches an SDK that
|
|
44
|
+
* reports nothing at all.
|
|
45
|
+
*/
|
|
46
|
+
export const SOURCE_START_BACKSTOP_MS = 30_000;
|
|
47
|
+
const SOURCE_ACQUISITION_TIMEOUT = Symbol('source-acquisition-timeout');
|
|
48
|
+
/**
|
|
49
|
+
* The bounded reason one SDK source failure reports.
|
|
50
|
+
*
|
|
51
|
+
* The SDK stages a start that produced no keyframe by what the source delivered, and a start that carried
|
|
52
|
+
* audio and never a single video frame is the one signature worth naming apart: measured on a real camera
|
|
53
|
+
* that had been switched off, the source accepted the start, delivered audio for the whole warm-up window,
|
|
54
|
+
* and reported `audio-only`. Every other stage is a source error, because nothing else distinguishes it
|
|
55
|
+
* from a transport that failed.
|
|
56
|
+
*/
|
|
57
|
+
function sourceFailure(error) {
|
|
58
|
+
if (error instanceof Error && error.name === 'StationBusyError') {
|
|
59
|
+
return 'station-busy';
|
|
60
|
+
}
|
|
61
|
+
return error instanceof LiveStreamStartError && error.stage === 'audio-only' ? 'source-audio-only' : 'source-error';
|
|
62
|
+
}
|
|
63
|
+
/** How many diagnostic lines one adaptation retains, which is what a failure is attributed from. */
|
|
64
|
+
const ADAPTATION_STDERR_TAIL_LINES = 8;
|
|
65
|
+
/** The longest partial line held while waiting for its terminator, so a silent process cannot grow one. */
|
|
66
|
+
const ADAPTATION_STDERR_PARTIAL_BYTES = 512;
|
|
67
|
+
/** RFC 3550 fixed RTP header, RFC 3711 HMAC-SHA1-80 tag, and the RFC 6184 FU-A fragmentation header. */
|
|
68
|
+
const PACKET_OVERHEAD_BYTES = 12 + 10 + 2;
|
|
69
|
+
/**
|
|
70
|
+
* How far under the negotiated MTU the RTP muxer keeps a datagram. Measured on the bundled encoder at two
|
|
71
|
+
* MTUs: `pkt_size` 1200 emitted datagrams of at most 1196 bytes and 1378 emitted at most 1374.
|
|
72
|
+
*/
|
|
73
|
+
const PACKET_SIZE_HEADROOM_BYTES = 4;
|
|
74
|
+
/**
|
|
75
|
+
* The encoder budget one negotiated video selection leaves after paying for its own transport, in kbps.
|
|
76
|
+
*
|
|
77
|
+
* The negotiated bit rate bounds what the accessory transmits, not what it codes, so the RTP and SRTP bytes
|
|
78
|
+
* this session will add are reserved out of the ceiling instead of being spent beside it. Every packet costs
|
|
79
|
+
* a header, an authentication tag and a fragmentation header, and the packet count is the coded media divided
|
|
80
|
+
* by what one full packet can carry, plus one partial packet per frame.
|
|
81
|
+
*
|
|
82
|
+
* The negotiated frame rate is the figure used because it bounds packets per second exactly as it bounds
|
|
83
|
+
* pictures per second. A camera delivering below its selection therefore produces fewer packets than were
|
|
84
|
+
* reserved for, which spends the ceiling rather than exceeding it.
|
|
85
|
+
*
|
|
86
|
+
* A ceiling too small to pay for its own transport still yields a positive budget, because an encoder given
|
|
87
|
+
* a non-positive rate refuses to start and a refused session is worse than an overshooting one.
|
|
88
|
+
*/
|
|
89
|
+
function videoBudgetInsideCeiling(selection) {
|
|
90
|
+
const ceilingBytesPerSecond = selection.maxBitRate * 125;
|
|
91
|
+
const mediaBytesPerPacket = selection.mtu - PACKET_SIZE_HEADROOM_BYTES - PACKET_OVERHEAD_BYTES;
|
|
92
|
+
const packetsPerSecond = ceilingBytesPerSecond / mediaBytesPerPacket + selection.fps;
|
|
93
|
+
const reserved = (PACKET_OVERHEAD_BYTES * packetsPerSecond * 8) / 1_000;
|
|
94
|
+
return Math.max(1, Math.floor(selection.maxBitRate - reserved));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Splits one adaptation process's stderr into whole lines and retains a bounded tail of the diagnostic
|
|
98
|
+
* ones.
|
|
99
|
+
*
|
|
100
|
+
* Both consumers of that pipe read it: `-progress pipe:2` writes the key/value block that says adaptation
|
|
101
|
+
* reached its output, and everything else on it is the only account of why a process refused or stopped.
|
|
102
|
+
* Progress lines are excluded from the tail because they are emitted on a timer and would otherwise be the
|
|
103
|
+
* whole of it by the time anything went wrong.
|
|
104
|
+
*/
|
|
105
|
+
export class AdaptationStderr {
|
|
106
|
+
partial = '';
|
|
107
|
+
retained = [];
|
|
108
|
+
/** Consumes one chunk and returns the lines it completed, in the order they were written. */
|
|
109
|
+
observe(chunk) {
|
|
110
|
+
const lines = `${this.partial}${chunk.toString()}`.split(/\r?\n/);
|
|
111
|
+
this.partial = lines.pop()?.slice(-ADAPTATION_STDERR_PARTIAL_BYTES) ?? '';
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.trim() === '' || line.startsWith('progress=')) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
this.retained.push(line);
|
|
117
|
+
if (this.retained.length > ADAPTATION_STDERR_TAIL_LINES) {
|
|
118
|
+
this.retained.shift();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
/** The retained diagnostic tail, oldest first. */
|
|
124
|
+
tail() {
|
|
125
|
+
return [...this.retained];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** How long the version probe is allowed to take before the binary counts as not having answered. */
|
|
129
|
+
const FFMPEG_VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
130
|
+
/** How much of the banner is kept: enough for the build and version, and no more. */
|
|
131
|
+
const FFMPEG_VERSION_BANNER_LENGTH = 120;
|
|
132
|
+
/**
|
|
133
|
+
* Runs `-version` on one path and returns its first banner line, or nothing where it produced none.
|
|
134
|
+
*
|
|
135
|
+
* Every failure answers the same way, because none of them can be told apart by a caller that only needs
|
|
136
|
+
* to know whether this binary can be asked to encode: a missing file, a file that is not executable, one
|
|
137
|
+
* that is not FFmpeg, and one that hangs all mean the same thing to the media that would have used it.
|
|
138
|
+
*/
|
|
139
|
+
const probeFfmpegVersion = (path) => new Promise((resolve) => {
|
|
140
|
+
execFile(path, ['-hide_banner', '-version'], { timeout: FFMPEG_VERSION_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true }, (error, stdout) => {
|
|
141
|
+
const banner = error ? undefined : stdout.split(/\r?\n/, 1)[0]?.trim().slice(0, FFMPEG_VERSION_BANNER_LENGTH);
|
|
142
|
+
resolve(banner === undefined || banner === '' ? undefined : banner);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* Resolves the complete identity of one adaptation binary, without opening any media.
|
|
147
|
+
*
|
|
148
|
+
* Reading the banner spawns the binary, so this is asked once at launch and its answer is not waited on: a
|
|
149
|
+
* support archive is worth one process at startup, and no camera is worth delaying for one.
|
|
150
|
+
*/
|
|
151
|
+
export async function resolveFfmpegIdentity(path, source, probe = probeFfmpegVersion) {
|
|
152
|
+
const version = await probe(path).catch(() => undefined);
|
|
153
|
+
return { path, source, ...(version === undefined ? {} : { version }) };
|
|
154
|
+
}
|
|
155
|
+
/** Adapts separate SDK elementary streams into independently failing HomeKit SRTP outputs. */
|
|
156
|
+
export class FfmpegLiveMedia {
|
|
157
|
+
executable;
|
|
158
|
+
adaptationDiagnostics;
|
|
159
|
+
createProcess;
|
|
160
|
+
reservePort;
|
|
161
|
+
createReturnAudioProcess;
|
|
162
|
+
constructor(executable, adaptationDiagnostics, createProcess = spawnLiveMediaProcess, reservePort = reserveMediaPort, createReturnAudioProcess = spawnReturnAudioProcess) {
|
|
163
|
+
this.executable = executable;
|
|
164
|
+
this.adaptationDiagnostics = adaptationDiagnostics;
|
|
165
|
+
this.createProcess = createProcess;
|
|
166
|
+
this.reservePort = reservePort;
|
|
167
|
+
this.createReturnAudioProcess = createReturnAudioProcess;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reserves the negotiated output ports so `SetupEndpoints` can be answered with them, and returns a
|
|
171
|
+
* session that holds nothing else: no SDK handle, adaptation process, or device session exists until
|
|
172
|
+
* `start`. The reservation therefore lives for as long as its HomeKit consumer keeps the session, and
|
|
173
|
+
* `stop` releases every reservation exactly once however the session ended.
|
|
174
|
+
*/
|
|
175
|
+
async prepare(transport) {
|
|
176
|
+
const videoPort = await this.reservePort(transport.addressVersion);
|
|
177
|
+
const targetAddress = transport.addressVersion === 'ipv6' ? `[${transport.targetAddress}]` : transport.targetAddress;
|
|
178
|
+
let audioPort;
|
|
179
|
+
try {
|
|
180
|
+
audioPort = transport.audio ? await this.reservePort(transport.addressVersion) : undefined;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
videoPort.close();
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let source;
|
|
187
|
+
let videoProcess;
|
|
188
|
+
let audioProcess;
|
|
189
|
+
let returnAudioProcess;
|
|
190
|
+
let talkbackHandle;
|
|
191
|
+
let talkbackSink;
|
|
192
|
+
let talkbackStarting = false;
|
|
193
|
+
let talkbackEnded = false;
|
|
194
|
+
let negotiated;
|
|
195
|
+
let stopped = false;
|
|
196
|
+
let receivedVideoKeyframe = false;
|
|
197
|
+
let reconfigurationPending = false;
|
|
198
|
+
/**
|
|
199
|
+
* The coded configuration the source last announced, and the one the running adaptation was opened for.
|
|
200
|
+
*
|
|
201
|
+
* Both, rather than a flag, because only a difference between them is a reason to replace an adaptation:
|
|
202
|
+
* announcements arrive before the first keyframe has opened one, and a configuration that moves away and
|
|
203
|
+
* back leaves the running adaptation still able to code what follows. What counts as a difference is
|
|
204
|
+
* {@link canCode}.
|
|
205
|
+
*/
|
|
206
|
+
let videoConfig;
|
|
207
|
+
let adaptationConfig;
|
|
208
|
+
/** How many adaptation replacements the source's own changes have asked this session for. */
|
|
209
|
+
let sourceInputReplacements = 0;
|
|
210
|
+
let audioInputCodec;
|
|
211
|
+
let rtcpDeadline;
|
|
212
|
+
let initialRtcpGrace;
|
|
213
|
+
let videoStartBackstop;
|
|
214
|
+
let videoFailed = false;
|
|
215
|
+
let rtcpObserved = false;
|
|
216
|
+
let streaming = false;
|
|
217
|
+
const stoppingProcesses = new WeakSet();
|
|
218
|
+
const congested = new Set();
|
|
219
|
+
const { adaptationDiagnostics } = this;
|
|
220
|
+
/**
|
|
221
|
+
* Releases one adaptation input's claim on the source, whether it drained or stopped existing.
|
|
222
|
+
*
|
|
223
|
+
* An adaptation that is replaced or exits while full never emits the `drain` its claim is waiting for, so
|
|
224
|
+
* discharging the claim is part of ending the process rather than something only a successful drain does:
|
|
225
|
+
* otherwise one stalled process that a geometry change already replaced would hold the source for the
|
|
226
|
+
* rest of the session. It is idempotent and claimed from both directions — the process ending, and its
|
|
227
|
+
* input closing — because an adaptation can stop either way and neither alone covers both.
|
|
228
|
+
*/
|
|
229
|
+
const releaseAdaptation = (sink) => {
|
|
230
|
+
if (congested.delete(sink) && !stopped && congested.size === 0) {
|
|
231
|
+
source?.resume();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Writes one media payload to an adaptation, holding the SDK source while that input stays full.
|
|
236
|
+
*
|
|
237
|
+
* A discarded write result relocates a keyframe-aware bounded queue in the SDK into a byte-blind pipe
|
|
238
|
+
* buffer, which then grows in plugin heap for the life of the session and stalls the event loop with it.
|
|
239
|
+
* Holding the source instead arms the SDK's own drop-to-keyframe policy, so a session that cannot keep up
|
|
240
|
+
* resynchronises on decodable media rather than replaying a stale backlog.
|
|
241
|
+
*
|
|
242
|
+
* One source feeds both adaptations, so it is held while *any* of them is full and released only once
|
|
243
|
+
* every one has drained. That keeps a single source of truth for backpressure on this path: no queue,
|
|
244
|
+
* threshold, or drop rule is invented here.
|
|
245
|
+
*/
|
|
246
|
+
const writeToAdaptation = (sink, data) => {
|
|
247
|
+
if (sink.write(data) || congested.has(sink)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
congested.add(sink);
|
|
251
|
+
if (congested.size === 1) {
|
|
252
|
+
source?.pause();
|
|
253
|
+
}
|
|
254
|
+
sink.once('drain', () => releaseAdaptation(sink));
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Reports what one adaptation process did, whether or not the session it belonged to fails for it.
|
|
258
|
+
*
|
|
259
|
+
* An audio adaptation is restarted rather than failed and a return-audio one fails only talkback, so
|
|
260
|
+
* without a report of their own those two exit with no account anywhere of why they stopped. An `output`
|
|
261
|
+
* report carries nothing but the tail, so it is made only when the process actually wrote something:
|
|
262
|
+
* a silent process that ended as intended has nothing to attribute.
|
|
263
|
+
*/
|
|
264
|
+
const reportAdaptation = (role, event, stderr, code, signal) => {
|
|
265
|
+
const tail = stderr.tail();
|
|
266
|
+
if (event === 'output' && tail.length === 0) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
adaptationDiagnostics?.report({
|
|
270
|
+
role,
|
|
271
|
+
event,
|
|
272
|
+
...(typeof code === 'number' ? { code } : {}),
|
|
273
|
+
...(typeof signal === 'string' ? { signal } : {}),
|
|
274
|
+
...(tail.length ? { stderr: tail } : {}),
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
const stopProcess = (process) => {
|
|
278
|
+
if (!process) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
stoppingProcesses.add(process);
|
|
282
|
+
process.stdin.destroy();
|
|
283
|
+
releaseAdaptation(process.stdin);
|
|
284
|
+
process.kill('SIGTERM');
|
|
285
|
+
const killDeadline = setTimeout(() => process.kill('SIGKILL'), PROCESS_STOP_GRACE_MS);
|
|
286
|
+
killDeadline.unref?.();
|
|
287
|
+
process.on('exit', () => clearTimeout(killDeadline));
|
|
288
|
+
};
|
|
289
|
+
/** Stop a caller-owned SDK handle without allowing synchronous or asynchronous faults to abort cleanup. */
|
|
290
|
+
const stopTalkbackHandle = (handle) => {
|
|
291
|
+
try {
|
|
292
|
+
void handle.stop().catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
295
|
+
};
|
|
296
|
+
const stopTalkback = (stopHandle = true) => {
|
|
297
|
+
if (talkbackEnded) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
talkbackEnded = true;
|
|
301
|
+
stopProcess(returnAudioProcess);
|
|
302
|
+
returnAudioProcess = undefined;
|
|
303
|
+
talkbackSink?.destroy();
|
|
304
|
+
talkbackSink = undefined;
|
|
305
|
+
const handle = talkbackHandle;
|
|
306
|
+
talkbackHandle = undefined;
|
|
307
|
+
if (stopHandle && handle) {
|
|
308
|
+
stopTalkbackHandle(handle);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const failTalkback = (reason) => {
|
|
312
|
+
if (stopped || talkbackEnded) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
transport.onTalkbackOutcome?.({ outcome: 'failed', reason });
|
|
316
|
+
stopTalkback();
|
|
317
|
+
};
|
|
318
|
+
const stop = () => {
|
|
319
|
+
if (stopped) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
stopped = true;
|
|
323
|
+
clearTimeout(rtcpDeadline);
|
|
324
|
+
clearTimeout(initialRtcpGrace);
|
|
325
|
+
clearTimeout(videoStartBackstop);
|
|
326
|
+
stopTalkback();
|
|
327
|
+
stopProcess(videoProcess);
|
|
328
|
+
stopProcess(audioProcess);
|
|
329
|
+
if (source) {
|
|
330
|
+
source.stop();
|
|
331
|
+
transport.onSessionReleased?.();
|
|
332
|
+
}
|
|
333
|
+
videoPort.close();
|
|
334
|
+
audioPort?.close();
|
|
335
|
+
};
|
|
336
|
+
const failVideo = (reason) => {
|
|
337
|
+
if (stopped || videoFailed) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
videoFailed = true;
|
|
341
|
+
stop();
|
|
342
|
+
const stage = reason === 'source-acquisition-timeout' || source === undefined
|
|
343
|
+
? 'sdk-source-acquisition'
|
|
344
|
+
: reason === 'rtcp-timeout'
|
|
345
|
+
? 'controller-rtcp'
|
|
346
|
+
: !receivedVideoKeyframe
|
|
347
|
+
? 'first-source-keyframe'
|
|
348
|
+
: 'first-adapted-output';
|
|
349
|
+
transport.onSessionOutcome?.({ outcome: 'failed', reason, stage });
|
|
350
|
+
transport.onVideoFailure?.();
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* RTCP liveness bounds a session that is already sending media. Before adaptation reaches the
|
|
354
|
+
* negotiated output the start backstop owns the bound, so an early datagram only records that the
|
|
355
|
+
* controller is present; arming the recurring deadline here would fail a source that legitimately
|
|
356
|
+
* warms for longer than one RTCP interval.
|
|
357
|
+
*/
|
|
358
|
+
const resetRtcpDeadline = () => {
|
|
359
|
+
rtcpObserved = true;
|
|
360
|
+
clearTimeout(initialRtcpGrace);
|
|
361
|
+
clearTimeout(rtcpDeadline);
|
|
362
|
+
if (!streaming) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const interval = Math.max(negotiated?.video.rtcpInterval ?? 1, 1) * 5_000;
|
|
366
|
+
rtcpDeadline = setTimeout(() => failVideo('rtcp-timeout'), interval);
|
|
367
|
+
rtcpDeadline.unref?.();
|
|
368
|
+
};
|
|
369
|
+
videoPort.onMessage(resetRtcpDeadline);
|
|
370
|
+
/**
|
|
371
|
+
* Adaptation reached the negotiated output, so the session is bounded from here by RTCP liveness
|
|
372
|
+
* rather than by the start backstop. The initial grace is armed from this point because media may
|
|
373
|
+
* legitimately start well after the session does.
|
|
374
|
+
*
|
|
375
|
+
* A selection HomeKit has already replaced does not discharge the deadline for its replacement, however
|
|
376
|
+
* much it keeps reporting: FFmpeg reports progress on a timer whether or not new media reaches it, so
|
|
377
|
+
* only the adaptation that carries the current selection can say that selection is being served.
|
|
378
|
+
*/
|
|
379
|
+
const observeAdaptationProgress = (reporter) => {
|
|
380
|
+
if (stopped || (reconfigurationPending && reporter === videoProcess)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
clearTimeout(videoStartBackstop);
|
|
384
|
+
videoStartBackstop = undefined;
|
|
385
|
+
if (streaming) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
streaming = true;
|
|
389
|
+
if (rtcpObserved) {
|
|
390
|
+
resetRtcpDeadline();
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
initialRtcpGrace = setTimeout(() => failVideo('rtcp-timeout'), INITIAL_RTCP_GRACE_MS);
|
|
394
|
+
initialRtcpGrace.unref?.();
|
|
395
|
+
}
|
|
396
|
+
transport.onSessionOutcome?.({ outcome: 'streaming' });
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Records the coded configuration the source is about to deliver. Nothing is replaced here: a replacement
|
|
400
|
+
* can only start on a keyframe, and this arrives ahead of the frame carrying the new configuration.
|
|
401
|
+
*/
|
|
402
|
+
const observeVideoConfig = (config) => {
|
|
403
|
+
videoConfig = config;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* Writes one source access unit to the adaptation that is entitled to code it.
|
|
407
|
+
*
|
|
408
|
+
* A source configuration the running adaptation was not opened for cannot be coded by it at all, so those
|
|
409
|
+
* frames wait for the keyframe that lets a replacement start. A reconfigured HomeKit selection changes
|
|
410
|
+
* only the output, so the current process keeps coding and keeps the previous selection on the wire until
|
|
411
|
+
* that same keyframe arrives; a controller reconfigures precisely when it is unhappy with what it
|
|
412
|
+
* receives, and the source is then often the very thing not producing keyframes.
|
|
413
|
+
*
|
|
414
|
+
* Source-driven replacement is bounded by {@link MAX_SOURCE_INPUT_REPLACEMENTS}, past which the session
|
|
415
|
+
* fails rather than spawn again. A controller-driven one is not: the controller bounds how often it asks.
|
|
416
|
+
*/
|
|
417
|
+
const writeVideo = (frame) => {
|
|
418
|
+
if (stopped || !negotiated || !videoConfig) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!receivedVideoKeyframe) {
|
|
422
|
+
if (!frame.keyframe) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
receivedVideoKeyframe = true;
|
|
426
|
+
}
|
|
427
|
+
const inputChanged = adaptationConfig !== undefined && !canCode(adaptationConfig, videoConfig);
|
|
428
|
+
if (videoProcess && inputChanged && !frame.keyframe) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (videoProcess && frame.keyframe && (inputChanged || reconfigurationPending)) {
|
|
432
|
+
if (inputChanged) {
|
|
433
|
+
sourceInputReplacements += 1;
|
|
434
|
+
}
|
|
435
|
+
if (sourceInputReplacements > MAX_SOURCE_INPUT_REPLACEMENTS) {
|
|
436
|
+
failVideo('source-input-unstable');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
reconfigurationPending = false;
|
|
440
|
+
stopProcess(videoProcess);
|
|
441
|
+
videoProcess = undefined;
|
|
442
|
+
}
|
|
443
|
+
if (!videoProcess) {
|
|
444
|
+
adaptationConfig = videoConfig;
|
|
445
|
+
const child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video));
|
|
446
|
+
videoProcess = child;
|
|
447
|
+
adaptationDiagnostics?.report({ role: 'live-video', event: 'started' });
|
|
448
|
+
const stderr = new AdaptationStderr();
|
|
449
|
+
let producedOutput = false;
|
|
450
|
+
child.stderr.on('data', (chunk) => {
|
|
451
|
+
if (stderr.observe(chunk).some((line) => line.startsWith('progress='))) {
|
|
452
|
+
producedOutput = true;
|
|
453
|
+
observeAdaptationProgress(child);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
child.stdin.on('error', () => {
|
|
457
|
+
if (!stoppingProcesses.has(child)) {
|
|
458
|
+
reportAdaptation('live-video', 'input-failed', stderr);
|
|
459
|
+
failVideo('adaptation-failed');
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
child.stdin.on('close', () => releaseAdaptation(child.stdin));
|
|
463
|
+
child.on('error', () => {
|
|
464
|
+
if (!stoppingProcesses.has(child)) {
|
|
465
|
+
reportAdaptation('live-video', producedOutput ? 'exited-while-streaming' : 'spawn-failed', stderr);
|
|
466
|
+
failVideo(producedOutput ? 'adaptation-exited-while-streaming' : 'adaptation-spawn-failed');
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
child.on('exit', (code, signal) => {
|
|
470
|
+
if (stopped || stoppingProcesses.has(child)) {
|
|
471
|
+
reportAdaptation('live-video', 'output', stderr, code, signal);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
reportAdaptation('live-video', producedOutput ? 'exited-while-streaming' : 'exited-before-output', stderr, code, signal);
|
|
475
|
+
failVideo(producedOutput ? 'adaptation-exited-while-streaming' : 'adaptation-exited-before-output');
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
writeToAdaptation(videoProcess.stdin, frame.data);
|
|
479
|
+
};
|
|
480
|
+
const writeAudio = (frame) => {
|
|
481
|
+
if (stopped || !negotiated?.audio || !transport.audio || !audioPort) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!audioProcess) {
|
|
485
|
+
audioInputCodec = frame.codec;
|
|
486
|
+
const child = this.createProcess(this.executable, audioArguments(frame, negotiated.audio, targetAddress, transport.audio));
|
|
487
|
+
audioProcess = child;
|
|
488
|
+
adaptationDiagnostics?.report({ role: 'live-audio', event: 'started' });
|
|
489
|
+
const stderr = new AdaptationStderr();
|
|
490
|
+
let producedOutput = false;
|
|
491
|
+
child.stderr.on('data', (chunk) => {
|
|
492
|
+
if (stderr.observe(chunk).some((line) => line.startsWith('progress='))) {
|
|
493
|
+
producedOutput = true;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
/**
|
|
497
|
+
* An audio adaptation is dropped rather than failed, so its own end has to discharge whatever claim
|
|
498
|
+
* it held on the source. Nothing else would if its input outlives it: the `drain` a full input is
|
|
499
|
+
* waiting for never arrives, and the video beside it would starve.
|
|
500
|
+
*/
|
|
501
|
+
const clearChild = () => {
|
|
502
|
+
releaseAdaptation(child.stdin);
|
|
503
|
+
if (audioProcess === child) {
|
|
504
|
+
audioProcess = undefined;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
child.stdin.on('close', () => releaseAdaptation(child.stdin));
|
|
508
|
+
child.stdin.on('error', () => {
|
|
509
|
+
if (!stoppingProcesses.has(child) && audioProcess === child) {
|
|
510
|
+
reportAdaptation('live-audio', 'input-failed', stderr);
|
|
511
|
+
}
|
|
512
|
+
stopProcess(child);
|
|
513
|
+
clearChild();
|
|
514
|
+
});
|
|
515
|
+
child.on('error', () => {
|
|
516
|
+
if (!stoppingProcesses.has(child)) {
|
|
517
|
+
reportAdaptation('live-audio', producedOutput ? 'exited-while-streaming' : 'spawn-failed', stderr);
|
|
518
|
+
}
|
|
519
|
+
stopProcess(child);
|
|
520
|
+
clearChild();
|
|
521
|
+
});
|
|
522
|
+
child.on('exit', (code, signal) => {
|
|
523
|
+
reportAdaptation('live-audio', stopped || stoppingProcesses.has(child)
|
|
524
|
+
? 'output'
|
|
525
|
+
: producedOutput
|
|
526
|
+
? 'exited-while-streaming'
|
|
527
|
+
: 'exited-before-output', stderr, code, signal);
|
|
528
|
+
clearChild();
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (audioInputCodec !== frame.codec) {
|
|
532
|
+
stopProcess(audioProcess);
|
|
533
|
+
audioProcess = undefined;
|
|
534
|
+
audioInputCodec = undefined;
|
|
535
|
+
writeAudio(frame);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
writeToAdaptation(audioProcess.stdin, frame.data);
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* Write one decoded return-audio chunk through the SDK writable that owns ADTS frame recovery,
|
|
542
|
+
* validation, pacing, and transport backpressure. The first chunk opens exactly one SDK handle;
|
|
543
|
+
* stdout stays paused while that handle is acquired so acquisition cannot create an unbounded queue.
|
|
544
|
+
*/
|
|
545
|
+
const writeReturnAudio = (camera, child, chunk) => {
|
|
546
|
+
if (stopped || talkbackEnded) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (talkbackSink) {
|
|
550
|
+
try {
|
|
551
|
+
if (!talkbackSink.write(chunk)) {
|
|
552
|
+
child.stdout.pause();
|
|
553
|
+
talkbackSink.once('drain', () => {
|
|
554
|
+
if (!stopped && !talkbackEnded) {
|
|
555
|
+
child.stdout.resume();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
failTalkback('device-audio-failed');
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (talkbackStarting || !camera.talkback) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
talkbackStarting = true;
|
|
569
|
+
child.stdout.pause();
|
|
570
|
+
let acquisition;
|
|
571
|
+
try {
|
|
572
|
+
acquisition = camera.talkback();
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
failTalkback('source-unavailable');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
void acquisition.then((handle) => {
|
|
579
|
+
if (stopped || talkbackEnded) {
|
|
580
|
+
stopTalkbackHandle(handle);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
talkbackHandle = handle;
|
|
584
|
+
try {
|
|
585
|
+
const sink = handle.writable();
|
|
586
|
+
talkbackSink = sink;
|
|
587
|
+
handle.on('budget', (notice) => {
|
|
588
|
+
if (!stopped && !talkbackEnded) {
|
|
589
|
+
notice.extend();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
handle.on('error', () => failTalkback('device-audio-failed'));
|
|
593
|
+
handle.on('stop', () => {
|
|
594
|
+
if (!stopped && !talkbackEnded && talkbackHandle === handle) {
|
|
595
|
+
failTalkback('device-audio-failed');
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
sink.on('error', () => failTalkback('device-audio-failed'));
|
|
599
|
+
const accepted = sink.write(chunk);
|
|
600
|
+
if (talkbackEnded) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
transport.onTalkbackOutcome?.({ outcome: 'talking' });
|
|
604
|
+
if (accepted) {
|
|
605
|
+
child.stdout.resume();
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
sink.once('drain', () => {
|
|
609
|
+
if (!stopped && !talkbackEnded) {
|
|
610
|
+
child.stdout.resume();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
failTalkback('device-audio-failed');
|
|
617
|
+
}
|
|
618
|
+
}, () => failTalkback('source-unavailable'));
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Hand the accessory audio endpoint to FFmpeg and give the spawned process a bounded bind grace before
|
|
622
|
+
* stream start is acknowledged. FFmpeg owns SRTP authentication, RTP AAC-ELD depacketization, decoding,
|
|
623
|
+
* and AAC-LC ADTS encoding; the SDK handle remains unopened until the process produces decoded audio.
|
|
624
|
+
*/
|
|
625
|
+
const startReturnAudio = async (camera, selection) => {
|
|
626
|
+
if (!camera.talkback || !selection || !transport.audio || !audioPort) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (selection.sampleRate !== 16 || selection.channels !== 1) {
|
|
630
|
+
failTalkback('unsupported-selection');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
await audioPort.close();
|
|
635
|
+
if (stopped || talkbackEnded) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const child = this.createReturnAudioProcess(this.executable, returnAudioArguments());
|
|
639
|
+
returnAudioProcess = child;
|
|
640
|
+
const stderr = new AdaptationStderr();
|
|
641
|
+
let producedOutput = false;
|
|
642
|
+
child.stderr.on('data', (chunk) => stderr.observe(chunk));
|
|
643
|
+
child.stdin.on('error', () => {
|
|
644
|
+
if (!stopped && !talkbackEnded && !stoppingProcesses.has(child)) {
|
|
645
|
+
reportAdaptation('return-audio', 'input-failed', stderr);
|
|
646
|
+
failTalkback('adaptation-failed');
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
child.stdout.on('data', (chunk) => {
|
|
650
|
+
producedOutput = true;
|
|
651
|
+
writeReturnAudio(camera, child, chunk);
|
|
652
|
+
});
|
|
653
|
+
child.stdout.on('error', () => {
|
|
654
|
+
if (!stopped && !talkbackEnded && !stoppingProcesses.has(child)) {
|
|
655
|
+
reportAdaptation('return-audio', 'input-failed', stderr);
|
|
656
|
+
failTalkback('adaptation-failed');
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
child.on('error', () => {
|
|
660
|
+
if (!stoppingProcesses.has(child)) {
|
|
661
|
+
reportAdaptation('return-audio', producedOutput ? 'exited-while-streaming' : 'spawn-failed', stderr);
|
|
662
|
+
failTalkback('adaptation-failed');
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
child.on('exit', (code, signal) => {
|
|
666
|
+
if (stopped || talkbackEnded || stoppingProcesses.has(child)) {
|
|
667
|
+
reportAdaptation('return-audio', 'output', stderr, code, signal);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
reportAdaptation('return-audio', producedOutput ? 'exited-while-streaming' : 'exited-before-output', stderr, code, signal);
|
|
671
|
+
failTalkback('adaptation-failed');
|
|
672
|
+
});
|
|
673
|
+
child.stdin.end(returnAudioSdp(audioPort.port, selection, transport.audio, transport.addressVersion));
|
|
674
|
+
await delay(RETURN_AUDIO_BIND_GRACE_MS);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
failTalkback('adaptation-failed');
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
return {
|
|
681
|
+
videoPort: videoPort.port,
|
|
682
|
+
...(audioPort ? { audioPort: audioPort.port } : {}),
|
|
683
|
+
async start(camera, selection) {
|
|
684
|
+
if (stopped) {
|
|
685
|
+
throw new Error('live media session is already stopped');
|
|
686
|
+
}
|
|
687
|
+
negotiated = selection;
|
|
688
|
+
const returnAudioReady = startReturnAudio(camera, selection.audio);
|
|
689
|
+
let sourcePromise;
|
|
690
|
+
let acquisitionDeadline;
|
|
691
|
+
try {
|
|
692
|
+
sourcePromise = camera.live();
|
|
693
|
+
void sourcePromise.then((lateSource) => {
|
|
694
|
+
if (stopped && source !== lateSource) {
|
|
695
|
+
lateSource.stop();
|
|
696
|
+
}
|
|
697
|
+
}, () => undefined);
|
|
698
|
+
source = await Promise.race([
|
|
699
|
+
sourcePromise,
|
|
700
|
+
new Promise((_, reject) => {
|
|
701
|
+
acquisitionDeadline = setTimeout(() => reject(SOURCE_ACQUISITION_TIMEOUT), SOURCE_ACQUISITION_DEADLINE_MS);
|
|
702
|
+
acquisitionDeadline.unref?.();
|
|
703
|
+
}),
|
|
704
|
+
]);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
failVideo(error === SOURCE_ACQUISITION_TIMEOUT ? 'source-acquisition-timeout' : sourceFailure(error));
|
|
708
|
+
throw error === SOURCE_ACQUISITION_TIMEOUT ? new Error('live media source acquisition timed out') : error;
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
clearTimeout(acquisitionDeadline);
|
|
712
|
+
}
|
|
713
|
+
if (stopped) {
|
|
714
|
+
source.stop();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
videoStartBackstop = setTimeout(() => failVideo('no-video-within-backstop'), SOURCE_START_BACKSTOP_MS);
|
|
718
|
+
videoStartBackstop.unref?.();
|
|
719
|
+
source.on('video-config', observeVideoConfig);
|
|
720
|
+
source.on('video', writeVideo);
|
|
721
|
+
source.on('audio', writeAudio);
|
|
722
|
+
source.on('budget', (notice) => {
|
|
723
|
+
if (!stopped) {
|
|
724
|
+
notice.extend();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
source.on('error', (error) => failVideo(sourceFailure(error)));
|
|
728
|
+
source.on('stop', () => failVideo('source-stopped'));
|
|
729
|
+
await returnAudioReady;
|
|
730
|
+
},
|
|
731
|
+
/**
|
|
732
|
+
* Acknowledges a reconfigured selection and applies it at the next source keyframe.
|
|
733
|
+
*
|
|
734
|
+
* Nothing is torn down here: the current adaptation keeps the previous selection on the wire until the
|
|
735
|
+
* replacement has a keyframe to start from. The deferral is still bounded, because a session that never
|
|
736
|
+
* applies the selection HomeKit asked for must end and be renegotiated rather than silently serve the
|
|
737
|
+
* old one forever.
|
|
738
|
+
*/
|
|
739
|
+
reconfigure(video) {
|
|
740
|
+
if (!negotiated) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
negotiated = { ...negotiated, video };
|
|
744
|
+
reconfigurationPending = videoProcess !== undefined;
|
|
745
|
+
clearTimeout(videoStartBackstop);
|
|
746
|
+
videoStartBackstop = setTimeout(() => failVideo('no-video-within-backstop'), SOURCE_START_BACKSTOP_MS);
|
|
747
|
+
videoStartBackstop.unref?.();
|
|
748
|
+
},
|
|
749
|
+
stop,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Options every adapted elementary stream applies to its own input.
|
|
755
|
+
*
|
|
756
|
+
* A piped elementary stream carries no container, so FFmpeg's initial stream analysis is the only thing
|
|
757
|
+
* standing between the first written access unit and the first coded frame. The caller already declares the
|
|
758
|
+
* format, so that analysis has nothing left to discover and is bounded to its minimum; leaving it at the
|
|
759
|
+
* default delays first output by seconds and scales that delay with the source keyframe interval.
|
|
760
|
+
*
|
|
761
|
+
* Every such input is read on one clock: when its media arrived. A pipe carries no timeline of its own, so
|
|
762
|
+
* arrival is the only timeline there is, and two adapted streams of one session can be presented together
|
|
763
|
+
* only while both are read on it.
|
|
764
|
+
*
|
|
765
|
+
* Nothing asks FFmpeg to discard or reinterpret what it read. Discarding the analysed packets throws away the
|
|
766
|
+
* leading keyframe the caller waited for, and a raw A-law demuxer stops emitting timestamps entirely, so both
|
|
767
|
+
* cost media that was already in hand.
|
|
768
|
+
*/
|
|
769
|
+
function commonArguments(inputFormat, inputOptions = []) {
|
|
770
|
+
return [
|
|
771
|
+
'-hide_banner',
|
|
772
|
+
'-loglevel',
|
|
773
|
+
'warning',
|
|
774
|
+
'-probesize',
|
|
775
|
+
'32',
|
|
776
|
+
'-analyzeduration',
|
|
777
|
+
'1',
|
|
778
|
+
'-progress',
|
|
779
|
+
'pipe:2',
|
|
780
|
+
'-nostats',
|
|
781
|
+
'-f',
|
|
782
|
+
inputFormat,
|
|
783
|
+
...inputOptions,
|
|
784
|
+
'-use_wallclock_as_timestamps',
|
|
785
|
+
'1',
|
|
786
|
+
'-i',
|
|
787
|
+
'pipe:0',
|
|
788
|
+
];
|
|
789
|
+
}
|
|
790
|
+
function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
|
|
791
|
+
return [
|
|
792
|
+
'-payload_type',
|
|
793
|
+
String(payloadType),
|
|
794
|
+
'-ssrc',
|
|
795
|
+
String(ssrc),
|
|
796
|
+
'-f',
|
|
797
|
+
'rtp',
|
|
798
|
+
'-srtp_out_suite',
|
|
799
|
+
target.srtpCryptoSuite,
|
|
800
|
+
'-srtp_out_params',
|
|
801
|
+
Buffer.concat([target.srtpKey, target.srtpSalt]).toString('base64'),
|
|
802
|
+
`srtp://${targetAddress}:${target.port}?rtcpport=${target.port}&pkt_size=${packetSize}`,
|
|
803
|
+
];
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Raw SDK frames cannot prove profile, level, frame rate, and bitrate, so negotiated video is transcoded.
|
|
807
|
+
*
|
|
808
|
+
* Asking FFmpeg to generate presentation timestamps rather than read the arrival ones every adapted input
|
|
809
|
+
* carries makes it interpolate them from a frame rate a bare Annex-B pipe never states, which collapses the
|
|
810
|
+
* whole session onto one instant; the constant-rate output then resolves the collision by discarding almost
|
|
811
|
+
* every frame it was given.
|
|
812
|
+
*
|
|
813
|
+
* A negotiated frame rate is a CEILING, not a cadence, so the rate is bounded rather than pinned. Pinning it
|
|
814
|
+
* makes the encoder duplicate frames the source never sent, and each duplicate costs a full encode and a share
|
|
815
|
+
* of the negotiated bit rate for a picture carrying nothing new. Measured on a 1600x1200 doorbell delivering
|
|
816
|
+
* about 15 fps against a 30 fps selection: 264 of 267 emitted frames were duplicates and the adaptation ran at
|
|
817
|
+
* 0.32x real time, so it fell further behind every second and the session died on its backstop with nothing
|
|
818
|
+
* watchable reaching the controller.
|
|
819
|
+
*
|
|
820
|
+
* A negotiated selection states no refresh cadence, so the keyframe interval is plugin policy: `-g` and
|
|
821
|
+
* `-keyint_min` are the negotiated rate doubled and scene-cut detection is off, which is a refresh every two
|
|
822
|
+
* negotiated seconds and nothing between them. It is the only repair a live session has, because this output is
|
|
823
|
+
* unretransmitted SRTP over UDP and nothing here asks the encoder for a picture out of cadence, so one lost in
|
|
824
|
+
* transit persists until the next refresh. What the interval costs is in docs/architecture.md.
|
|
825
|
+
*
|
|
826
|
+
* A negotiated bit rate is a ceiling on what the accessory TRANSMITS, so the encoder is given the ceiling less
|
|
827
|
+
* the transport its own packetization adds, and its VBV buffer holds one second of that budget rather than
|
|
828
|
+
* two. A buffer is an allowance a window may exceed the rate by, so a two-second buffer owes a 45-second
|
|
829
|
+
* session 4.4 percent over its ceiling on arithmetic alone, before any transport is counted. An encoder coding
|
|
830
|
+
* inside its ceiling still transmits over it whenever the transport is unreserved, which is what the measured
|
|
831
|
+
* overshoot was; the evidence is in docs/architecture.md.
|
|
832
|
+
*
|
|
833
|
+
* `superfast` is the cheapest `libx264` preset that retains CABAC, and therefore the cheapest one whose
|
|
834
|
+
* coded stream can carry a negotiated Main or High profile; `ultrafast` drops CABAC and codes Constrained
|
|
835
|
+
* Baseline whatever `-profile:v` asks for. `-tune zerolatency` pins the same `sliced_threads`, `bframes`
|
|
836
|
+
* and `rc_lookahead` at either preset, so this costs computation rather than frame delay.
|
|
837
|
+
*/
|
|
838
|
+
function videoArguments(input, selection, targetAddress, target) {
|
|
839
|
+
const budget = videoBudgetInsideCeiling(selection);
|
|
840
|
+
return [
|
|
841
|
+
...commonArguments(input.codec === 'h265' ? 'hevc' : input.codec),
|
|
842
|
+
'-an',
|
|
843
|
+
'-c:v',
|
|
844
|
+
'libx264',
|
|
845
|
+
'-preset',
|
|
846
|
+
'superfast',
|
|
847
|
+
'-tune',
|
|
848
|
+
'zerolatency',
|
|
849
|
+
'-profile:v',
|
|
850
|
+
selection.profile,
|
|
851
|
+
'-level:v',
|
|
852
|
+
selection.level,
|
|
853
|
+
'-pix_fmt',
|
|
854
|
+
'yuv420p',
|
|
855
|
+
'-vf',
|
|
856
|
+
`scale=${selection.width}:${selection.height}:force_original_aspect_ratio=decrease,pad=${selection.width}:${selection.height}:(ow-iw)/2:(oh-ih)/2`,
|
|
857
|
+
'-fpsmax',
|
|
858
|
+
String(selection.fps),
|
|
859
|
+
'-g',
|
|
860
|
+
String(selection.fps * 2),
|
|
861
|
+
'-keyint_min',
|
|
862
|
+
String(selection.fps * 2),
|
|
863
|
+
'-sc_threshold',
|
|
864
|
+
'0',
|
|
865
|
+
'-b:v',
|
|
866
|
+
`${budget}k`,
|
|
867
|
+
'-maxrate',
|
|
868
|
+
`${budget}k`,
|
|
869
|
+
'-bufsize',
|
|
870
|
+
`${budget}k`,
|
|
871
|
+
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu),
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Adapts one source audio elementary stream to the negotiated AAC-ELD output.
|
|
876
|
+
*
|
|
877
|
+
* The SDK reports no sample rate or channel count, because a station sends neither; 16 kHz mono is the
|
|
878
|
+
* assumption every Eufy client applies. Raw A-law carries nothing at all and must be told that assumption,
|
|
879
|
+
* while an ADTS input states its own rate in every frame header and rejects the option outright, which fails
|
|
880
|
+
* the process before it reads a byte.
|
|
881
|
+
*
|
|
882
|
+
* Asynchronous resampling is what makes this output honour the arrival clock its input is read on. An encoder
|
|
883
|
+
* advances its output timeline by the samples in each coded frame it is handed, whatever timestamp arrived
|
|
884
|
+
* with them, so the arrival stamp alone would move only where the timeline starts. The resampler resolves
|
|
885
|
+
* every frame against that stamp instead, which bounds the output to real time however much content a source
|
|
886
|
+
* hands over at once.
|
|
887
|
+
*
|
|
888
|
+
* It resolves an excess by DISCARDING it, so a source that hands over more audio than it has been running for
|
|
889
|
+
* loses the excess permanently rather than being played back late. That is this session's policy and not an
|
|
890
|
+
* accident of the filter: a live view owes its controller the newest audio, and audio which is already
|
|
891
|
+
* seconds stale by the time it arrives cannot be presented beside a picture timestamped on arrival.
|
|
892
|
+
*
|
|
893
|
+
* `libfdk_aac` selects its transport from the requested output framing, and AAC-ELD cannot be carried in
|
|
894
|
+
* ADTS, so without an explicit global header the encoder refuses to initialise at all.
|
|
895
|
+
*/
|
|
896
|
+
function audioArguments(frame, selection, targetAddress, target) {
|
|
897
|
+
const rawAlaw = frame.codec === 'g711a';
|
|
898
|
+
return [
|
|
899
|
+
...commonArguments(rawAlaw ? 'alaw' : 'aac', rawAlaw ? ['-ar', '16k', '-ac', '1'] : []),
|
|
900
|
+
'-vn',
|
|
901
|
+
'-c:a',
|
|
902
|
+
'libfdk_aac',
|
|
903
|
+
'-profile:a',
|
|
904
|
+
'aac_eld',
|
|
905
|
+
'-flags',
|
|
906
|
+
'+global_header',
|
|
907
|
+
'-af',
|
|
908
|
+
'aresample=async=1',
|
|
909
|
+
'-ar',
|
|
910
|
+
`${selection.sampleRate}k`,
|
|
911
|
+
'-ac',
|
|
912
|
+
String(selection.channels),
|
|
913
|
+
'-b:a',
|
|
914
|
+
`${selection.maxBitRate}k`,
|
|
915
|
+
...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, 188),
|
|
916
|
+
];
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Decode HomeKit's controller-to-accessory AAC-ELD SRTP and emit the SDK's exact talkback input: 16 kHz
|
|
920
|
+
* mono AAC-LC in ADTS. The SDK owns complete-frame recovery, the 640-byte frame limit, and 64 ms pacing,
|
|
921
|
+
* so FFmpeg must produce a byte stream rather than impose another clock.
|
|
922
|
+
*/
|
|
923
|
+
function returnAudioArguments() {
|
|
924
|
+
return [
|
|
925
|
+
'-hide_banner',
|
|
926
|
+
'-loglevel',
|
|
927
|
+
'warning',
|
|
928
|
+
'-nostats',
|
|
929
|
+
'-protocol_whitelist',
|
|
930
|
+
'pipe,udp,rtp,crypto',
|
|
931
|
+
'-f',
|
|
932
|
+
'sdp',
|
|
933
|
+
'-c:a',
|
|
934
|
+
'libfdk_aac',
|
|
935
|
+
'-i',
|
|
936
|
+
'pipe:0',
|
|
937
|
+
'-map',
|
|
938
|
+
'0:a:0',
|
|
939
|
+
'-vn',
|
|
940
|
+
'-c:a',
|
|
941
|
+
'libfdk_aac',
|
|
942
|
+
'-profile:a',
|
|
943
|
+
'aac_low',
|
|
944
|
+
'-ar',
|
|
945
|
+
'16k',
|
|
946
|
+
'-ac',
|
|
947
|
+
'1',
|
|
948
|
+
'-b:a',
|
|
949
|
+
'32k',
|
|
950
|
+
'-f',
|
|
951
|
+
'adts',
|
|
952
|
+
'pipe:1',
|
|
953
|
+
];
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Describe the one return-audio RTP stream HomeKit negotiated. AAC-ELD at 16 kHz mono uses the
|
|
957
|
+
* established MPEG4-GENERIC AAC-hbr configuration below; the crypto material is the audio endpoint key
|
|
958
|
+
* HomeKit supplied during SetupEndpoints and no media or identity enters diagnostics.
|
|
959
|
+
*/
|
|
960
|
+
function returnAudioSdp(port, selection, target, addressVersion) {
|
|
961
|
+
const network = addressVersion === 'ipv6' ? 'IP6' : 'IP4';
|
|
962
|
+
const address = addressVersion === 'ipv6' ? '::' : '0.0.0.0';
|
|
963
|
+
const key = Buffer.concat([target.srtpKey, target.srtpSalt]).toString('base64');
|
|
964
|
+
return [
|
|
965
|
+
'v=0',
|
|
966
|
+
`o=- 0 0 IN ${network} ${address}`,
|
|
967
|
+
's=HomeKit Return Audio',
|
|
968
|
+
`c=IN ${network} ${address}`,
|
|
969
|
+
't=0 0',
|
|
970
|
+
`m=audio ${port} RTP/AVP ${selection.payloadType}`,
|
|
971
|
+
`b=AS:${selection.maxBitRate}`,
|
|
972
|
+
`a=rtpmap:${selection.payloadType} MPEG4-GENERIC/16000/1`,
|
|
973
|
+
`a=fmtp:${selection.payloadType} profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; config=F8F0212C00BC00`,
|
|
974
|
+
`a=crypto:1 ${target.srtpCryptoSuite} inline:${key}`,
|
|
975
|
+
'a=rtcp-mux',
|
|
976
|
+
'',
|
|
977
|
+
].join('\r\n');
|
|
978
|
+
}
|
|
979
|
+
function spawnLiveMediaProcess(executable, args) {
|
|
980
|
+
return spawn(executable, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
981
|
+
}
|
|
982
|
+
function spawnReturnAudioProcess(executable, args) {
|
|
983
|
+
return spawn(executable, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
984
|
+
}
|
|
985
|
+
function reserveMediaPort(addressVersion) {
|
|
986
|
+
return new Promise((resolve, reject) => {
|
|
987
|
+
const socket = createSocket(addressVersion === 'ipv6' ? 'udp6' : 'udp4');
|
|
988
|
+
socket.once('error', reject);
|
|
989
|
+
socket.bind(0, () => {
|
|
990
|
+
const address = socket.address();
|
|
991
|
+
socket.removeListener('error', reject);
|
|
992
|
+
let closing;
|
|
993
|
+
resolve({
|
|
994
|
+
port: address.port,
|
|
995
|
+
onMessage: (listener) => socket.on('message', listener),
|
|
996
|
+
close: () => (closing ??= new Promise((done) => {
|
|
997
|
+
socket.close(done);
|
|
998
|
+
})),
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
//# sourceMappingURL=live-stream.js.map
|