@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,2218 @@
|
|
|
1
|
+
import { constants, createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { appendFile, chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { gunzip as gunzipCallback, gzip as gzipCallback } from 'node:zlib';
|
|
9
|
+
import { LIVE_TRACE_MESSAGE } from '@mega-yfue/eufy-sdk';
|
|
10
|
+
/**
|
|
11
|
+
* The SDK properties whose unconfirmed write this plugin can name, and what it calls them.
|
|
12
|
+
*
|
|
13
|
+
* A write is acknowledged when the transport carried it, and the SDK reports separately where the device
|
|
14
|
+
* never applied it. Only a member this plugin actually writes is translated: anything else is a property
|
|
15
|
+
* some other consumer of the same account asked for, and claiming it would report a condition against an
|
|
16
|
+
* accessory this plugin never touched.
|
|
17
|
+
*/
|
|
18
|
+
const UNCONFIRMED_WRITE_MEMBERS = {
|
|
19
|
+
enabled: { capability: 'camera', member: 'enabled' },
|
|
20
|
+
statusLed: { capability: 'camera', member: 'statusLed' },
|
|
21
|
+
nightVision: { capability: 'camera', member: 'nightVision' },
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The condition an unconfirmed write is reported under, or nothing where this plugin did not write it.
|
|
25
|
+
*
|
|
26
|
+
* It shares the code a failed control operation already uses, because the user's situation is the same one —
|
|
27
|
+
* a control that did not take — and differs only in how it was learnt: no error was raised, the device simply
|
|
28
|
+
* never reported the value. The reason names that distinction so a support case can tell the two apart.
|
|
29
|
+
*/
|
|
30
|
+
export function unconfirmedWriteCondition(property) {
|
|
31
|
+
const named = Object.hasOwn(UNCONFIRMED_WRITE_MEMBERS, property) ? UNCONFIRMED_WRITE_MEMBERS[property] : undefined;
|
|
32
|
+
return named === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: { code: 'camera-control-operation-failed', ...named, active: true, reason: 'not-confirmed' };
|
|
35
|
+
}
|
|
36
|
+
/** The bounded reasons a stream request is refused before it reaches the source. */
|
|
37
|
+
const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
|
|
38
|
+
/**
|
|
39
|
+
* The live request and session events {@link sanitizeLiveSessionTrace} reconstructs a record from.
|
|
40
|
+
*
|
|
41
|
+
* Read by both halves of the path: {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
42
|
+
*/
|
|
43
|
+
const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
|
|
44
|
+
'live-request-refused',
|
|
45
|
+
'live-request-unaccounted',
|
|
46
|
+
'live-session-failed',
|
|
47
|
+
'live-session-released',
|
|
48
|
+
'live-session-streaming',
|
|
49
|
+
]);
|
|
50
|
+
const MAX_SDK_DETAILS = 16;
|
|
51
|
+
const MAX_LOG_RECORD_BYTES = 64 * 1024;
|
|
52
|
+
const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
|
|
53
|
+
const MAX_QUEUED_LOG_BYTES = 1024 * 1024;
|
|
54
|
+
/**
|
|
55
|
+
* How much of one evidence class a support archive carries, applied to each class on its own.
|
|
56
|
+
*
|
|
57
|
+
* Per class rather than as one shared pool. The classes a fault is diagnosed from are small and the SDK's is
|
|
58
|
+
* not, so one pool let the noisiest class decide how far back any of them reached — and because the read fills
|
|
59
|
+
* newest-first, a long session lost the diagnostic classes at their oldest end while SDK records stayed. Only
|
|
60
|
+
* the SDK's class reaches this bound in practice.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_SUPPORT_ARCHIVE_LOG_BYTES = 16 * 1024 * 1024;
|
|
63
|
+
const MAX_SUPPORT_ARCHIVE_MARKER_BYTES = 1024 * 1024;
|
|
64
|
+
const MAX_UI_EVENTS_BYTES = 64 * 1024;
|
|
65
|
+
const MAX_PENDING_UI_EVENTS = 8;
|
|
66
|
+
const SUPPORT_ARCHIVE_RETENTION_MS = 24 * 60 * 60 * 1_000;
|
|
67
|
+
const LOG_ROTATIONS = 3;
|
|
68
|
+
const LOG_DIRECTORY = 'logs';
|
|
69
|
+
const LOG_FILE = 'homebridge-eufy.jsonl';
|
|
70
|
+
const DIAGNOSTICS_DIRECTORY = 'diagnostics';
|
|
71
|
+
const GUIDED_SESSION_FILE = 'session.json';
|
|
72
|
+
const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
|
|
73
|
+
const UI_EVENTS_FILE = 'ui-events.jsonl';
|
|
74
|
+
const FFMPEG_ENVIRONMENT_FILE = 'ffmpeg.json';
|
|
75
|
+
/** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
|
|
76
|
+
const MAX_FFMPEG_IDENTITY_LENGTH = 256;
|
|
77
|
+
const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
78
|
+
/**
|
|
79
|
+
* The version declared by the nearest `package.json` above a resolved file, or `unknown` if none states one.
|
|
80
|
+
*
|
|
81
|
+
* The nearest manifest above a module is the one that owns it, which is the same rule Node itself applies, so
|
|
82
|
+
* this reports the build that is actually loaded rather than a version written down a second time. It walks
|
|
83
|
+
* rather than joining a fixed number of segments because the plugin's own module is a directory deeper once
|
|
84
|
+
* built, and an SDK resolved through a link answers from wherever the link points.
|
|
85
|
+
*/
|
|
86
|
+
function loadedVersion(from) {
|
|
87
|
+
if (from === undefined) {
|
|
88
|
+
return 'unknown';
|
|
89
|
+
}
|
|
90
|
+
for (let directory = dirname(from); directory !== dirname(directory); directory = dirname(directory)) {
|
|
91
|
+
try {
|
|
92
|
+
const declared = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
|
|
93
|
+
.version;
|
|
94
|
+
return typeof declared === 'string' ? declared : 'unknown';
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return 'unknown';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Where the SDK the plugin loaded resides, or `undefined` when nothing answers the specifier.
|
|
104
|
+
*
|
|
105
|
+
* Resolution fails on its own terms and separately from reading: an installation that has not installed its
|
|
106
|
+
* dependencies resolves nothing, and a version label is not worth refusing to load diagnostics over.
|
|
107
|
+
*/
|
|
108
|
+
function loadedSdkEntry() {
|
|
109
|
+
try {
|
|
110
|
+
return createRequire(import.meta.url).resolve('@mega-yfue/eufy-sdk');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The plugin build this process loaded, read once at load from the manifest above this module.
|
|
118
|
+
*
|
|
119
|
+
* A support archive is read to decide whether a fault is already fixed, and without this an archive cannot
|
|
120
|
+
* answer that. Failure degrades to `unknown` rather than a guess, because a wrong version sends the reader
|
|
121
|
+
* further from the fault than an absent one does.
|
|
122
|
+
*
|
|
123
|
+
* It states the build in memory rather than the one on disk, which is what lets two processes notice an upgrade
|
|
124
|
+
* between them: after one lands, the runtime keeps answering with the build it started on until it is replaced.
|
|
125
|
+
*/
|
|
126
|
+
export const PLUGIN_VERSION = loadedVersion(fileURLToPath(import.meta.url));
|
|
127
|
+
const SDK_VERSION = loadedVersion(loadedSdkEntry());
|
|
128
|
+
const gzip = promisify(gzipCallback);
|
|
129
|
+
const gunzip = promisify(gunzipCallback);
|
|
130
|
+
const SDK_SUBSYSTEMS = new Set(['device', 'mega', 'mqtt', 'p2p', 'push', 'webrtc', 'sdk']);
|
|
131
|
+
/**
|
|
132
|
+
* The events an SDK-scoped record may name, being the ones {@link createSdkLogger} classifies a message as.
|
|
133
|
+
*
|
|
134
|
+
* Disjoint from {@link HOMEKIT_LIVE_REQUEST_EVENTS}, which this plugin's own adapters report under `homekit`.
|
|
135
|
+
*/
|
|
136
|
+
const SDK_EVENT_KEYS = new Set([
|
|
137
|
+
'client-warning',
|
|
138
|
+
'connection-closed',
|
|
139
|
+
'connection-opened',
|
|
140
|
+
'connection-retrying',
|
|
141
|
+
'live-start-trace',
|
|
142
|
+
'media-error',
|
|
143
|
+
'media-warning',
|
|
144
|
+
'operation-failed',
|
|
145
|
+
'observation-invalid',
|
|
146
|
+
'protocol-command',
|
|
147
|
+
'protocol-unhandled',
|
|
148
|
+
'session-connecting',
|
|
149
|
+
'session-idle',
|
|
150
|
+
'session-resumed',
|
|
151
|
+
'session-restored',
|
|
152
|
+
'snapshot-cache-warning',
|
|
153
|
+
'transport-error',
|
|
154
|
+
]);
|
|
155
|
+
const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
|
|
156
|
+
/** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
|
|
157
|
+
const DIAGNOSTICS_UI_EVENTS = [
|
|
158
|
+
'background-started',
|
|
159
|
+
'dashboard-opened',
|
|
160
|
+
'authentication-opened',
|
|
161
|
+
'request-failed',
|
|
162
|
+
];
|
|
163
|
+
const SUPPORT_ARCHIVE_KEY = {
|
|
164
|
+
keyId: 'support-2026-08-01',
|
|
165
|
+
sha256: 'e01d8a1c6c2b800772495b3f656b10899364ece0e82d846f7d33f61cdffbd451',
|
|
166
|
+
publicKey: `-----BEGIN PUBLIC KEY-----
|
|
167
|
+
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArho8/7NwaYsZ3r27Lzek
|
|
168
|
+
mJXOdSOtjuxKLWHxS40Hf6MFskF/dSwY8om0NZ22Qa/cygStAiP4eAmL1fEuNqlS
|
|
169
|
+
JnoFgCtg6myFQVfeep9FWAJruR7EGp4WgiXopq8pvxSkJixZaYin2cch7CwUA5g9
|
|
170
|
+
b2AcErlfAmZM6F0Sd9v5Q7rHF68x3MINi3BTDKsz/3KqkoJoxyosKjMNDNATEn/T
|
|
171
|
+
y/yAF1/kELg7SJgnheWRoFK6130DEPzbym+TTxSZZOHeAtw27ALXoYGmnv09uq0K
|
|
172
|
+
Bks4wOrvW8gWQpbMOfpbc3XeWsP7bOuIXr/fs3kXgHIZoJSiLW7JlsivL9z5NDl6
|
|
173
|
+
UBxRyR0KOnK07Cx2xvl5pXfAOxQnc8F1JtjclzCHG6Q5sfq7isoMcpPuxDlEepgm
|
|
174
|
+
FNkJi71G4+lWgTotQr/fTVeZ46IxXrtnq89pb0fE20WYMaHnXkz0FMCIjMjuQWEP
|
|
175
|
+
R0zjeaRO8wZ3sqMgWSy2TldFsh709GqVUiS0YRUoVT1oExc27P47EFUNh57qI7bI
|
|
176
|
+
tcEIHVhBqyawK+WrIC+vgBgAPg6w5klxVhUaWGltubIFSm86BxNDTGx7C6rllcRU
|
|
177
|
+
pirqSQAW3PgOCg6d3lfkGLHVRsC+j6xsv1xC6clR6MKzklp7qz6uuOmf9GIdu+qE
|
|
178
|
+
9U0RwKqGYSp/N8TF5n3p3s0CAwEAAQ==
|
|
179
|
+
-----END PUBLIC KEY-----`,
|
|
180
|
+
};
|
|
181
|
+
const SUPPORT_ARCHIVE_EXCLUDED_CLASSES = [
|
|
182
|
+
'credentials-and-authentication',
|
|
183
|
+
'tokens-cookies-and-authorization',
|
|
184
|
+
'session-and-push-stores',
|
|
185
|
+
'private-and-symmetric-keys',
|
|
186
|
+
'unconstrained-sdk-objects',
|
|
187
|
+
'camera-images-talkback-and-raw-media',
|
|
188
|
+
];
|
|
189
|
+
const DIAGNOSTICS_PROFILES = {
|
|
190
|
+
'startup-authentication': ['plugin-log', 'sdk-log'],
|
|
191
|
+
'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
192
|
+
'control-state': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
193
|
+
'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
194
|
+
'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
|
|
195
|
+
'dashboard-ui': ['plugin-log', 'ui-log'],
|
|
196
|
+
other: ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
197
|
+
};
|
|
198
|
+
/** Narrows external profile input against the diagnostics-owned profile registry. */
|
|
199
|
+
export function isDiagnosticsProfile(value) {
|
|
200
|
+
return typeof value === 'string' && Object.hasOwn(DIAGNOSTICS_PROFILES, value);
|
|
201
|
+
}
|
|
202
|
+
/** Narrows external reproduction-mode input to the diagnostics-owned modes. */
|
|
203
|
+
export function isDiagnosticsReproductionMode(value) {
|
|
204
|
+
return value === 'now' || value === 'intermittent';
|
|
205
|
+
}
|
|
206
|
+
/** Narrows external event input to the diagnostics-owned UI vocabulary. */
|
|
207
|
+
export function isDiagnosticsUiEvent(value) {
|
|
208
|
+
return DIAGNOSTICS_UI_EVENTS.includes(value);
|
|
209
|
+
}
|
|
210
|
+
function diagnosticsSessionPath(storageRoot) {
|
|
211
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, GUIDED_SESSION_FILE);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Whether a value has the shape a support case identifier is issued in, which is the only shape one is
|
|
215
|
+
* accepted in from anywhere.
|
|
216
|
+
*
|
|
217
|
+
* A support case identifier names one authorized evidence window. It is randomly generated and carries no
|
|
218
|
+
* account, device, or host fact, which is why it may cross a process boundary.
|
|
219
|
+
*/
|
|
220
|
+
export function isSupportCaseId(value) {
|
|
221
|
+
return typeof value === 'string' && /^support-[0-9a-f-]{36}$/.test(value);
|
|
222
|
+
}
|
|
223
|
+
function readDiagnosticsSession(storageRoot) {
|
|
224
|
+
try {
|
|
225
|
+
const candidate = JSON.parse(readFileSync(diagnosticsSessionPath(storageRoot), 'utf8'));
|
|
226
|
+
const reproductionMode = candidate.reproductionMode ?? 'now';
|
|
227
|
+
if (candidate.version !== 1 ||
|
|
228
|
+
!isSupportCaseId(candidate.supportCaseId) ||
|
|
229
|
+
typeof candidate.profile !== 'string' ||
|
|
230
|
+
!isDiagnosticsProfile(candidate.profile) ||
|
|
231
|
+
!isDiagnosticsReproductionMode(reproductionMode) ||
|
|
232
|
+
typeof candidate.authorizedAt !== 'string' ||
|
|
233
|
+
typeof candidate.expiresAt !== 'string') {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return { ...candidate, reproductionMode };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function activeDiagnosticsSession(storageRoot, now) {
|
|
243
|
+
const session = readDiagnosticsSession(storageRoot);
|
|
244
|
+
return session && Date.parse(session.expiresAt) > now ? session : undefined;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Collapses one supplied string to bounded printable text, or nothing where none of it survives.
|
|
248
|
+
*
|
|
249
|
+
* Every value this module accepts from outside itself passes through here first, so that a field arriving as
|
|
250
|
+
* a novel, with control characters in it, or with a terminal escape sequence cannot become a record whatever
|
|
251
|
+
* the allowlist beyond it decides.
|
|
252
|
+
*/
|
|
253
|
+
function boundedText(value, maximumLength) {
|
|
254
|
+
const printable = value
|
|
255
|
+
.replace(/[^\x20-\x7e]+/g, ' ')
|
|
256
|
+
.replace(/\s+/g, ' ')
|
|
257
|
+
.trim()
|
|
258
|
+
.slice(0, maximumLength)
|
|
259
|
+
.trim();
|
|
260
|
+
return printable === '' ? undefined : printable;
|
|
261
|
+
}
|
|
262
|
+
function ffmpegEnvironmentPath(storageRoot) {
|
|
263
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FFMPEG_ENVIRONMENT_FILE);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Records which FFmpeg this run resolved, so a support archive states the build a failure came from.
|
|
267
|
+
*
|
|
268
|
+
* It is persisted rather than held in memory because the process that resolves it is not the one that
|
|
269
|
+
* assembles an archive, and it is rewritten on every start so a path or binary that changed between runs
|
|
270
|
+
* cannot be reported as the one in use. A write that fails is dropped: the archive then declares the
|
|
271
|
+
* environment without an FFmpeg identity, which is honest, whereas failing startup over a diagnostic file
|
|
272
|
+
* would cost the user their cameras.
|
|
273
|
+
*/
|
|
274
|
+
export function recordFfmpegEnvironment(storageRoot, ffmpeg) {
|
|
275
|
+
try {
|
|
276
|
+
const path = ffmpegEnvironmentPath(storageRoot);
|
|
277
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
278
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, ffmpeg })}\n`, { mode: 0o600 });
|
|
279
|
+
}
|
|
280
|
+
catch { }
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Reads the recorded adaptation binary, refusing a record whose own fields do not narrow.
|
|
284
|
+
*
|
|
285
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
286
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
287
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
288
|
+
*/
|
|
289
|
+
function readFfmpegEnvironment(storageRoot) {
|
|
290
|
+
try {
|
|
291
|
+
const candidate = JSON.parse(readFileSync(ffmpegEnvironmentPath(storageRoot), 'utf8'));
|
|
292
|
+
const ffmpeg = candidate.ffmpeg;
|
|
293
|
+
if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const recorded = ffmpeg;
|
|
297
|
+
if (typeof recorded.path !== 'string' || (recorded.source !== 'bundled' && recorded.source !== 'configured')) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
const path = boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH);
|
|
301
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
302
|
+
return path === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The one log evidence class a retained record belongs to.
|
|
312
|
+
*
|
|
313
|
+
* The four classes partition the records: each verbose scope owns its own and everything else is the plugin's,
|
|
314
|
+
* so a record counts against exactly one class's budget.
|
|
315
|
+
*/
|
|
316
|
+
function evidenceClassOf(record) {
|
|
317
|
+
const scope = String(record.scope);
|
|
318
|
+
if (scope === 'sdk')
|
|
319
|
+
return 'sdk-log';
|
|
320
|
+
if (scope === 'homekit')
|
|
321
|
+
return 'homekit-log';
|
|
322
|
+
if (scope === 'ffmpeg')
|
|
323
|
+
return 'ffmpeg-log';
|
|
324
|
+
return 'plugin-log';
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The classes a profile selected that a collection of that session's evidence carries no content for.
|
|
328
|
+
*
|
|
329
|
+
* The status a session reports and the manifest an archive carries name the same classes because both ask
|
|
330
|
+
* here, against the same collection.
|
|
331
|
+
*/
|
|
332
|
+
function missingEvidenceClasses(selected, collected) {
|
|
333
|
+
return selected.filter((evidence) => !collected.some((candidate) => candidate.evidence === evidence));
|
|
334
|
+
}
|
|
335
|
+
/** The scopes whose records are only kept while a support profile that declares them is authorized. */
|
|
336
|
+
const VERBOSE_SCOPES = new Set(['sdk', 'homekit', 'ffmpeg']);
|
|
337
|
+
/** Owns persisted, user-authorized diagnostic evidence windows and identity-free reproduction markers. */
|
|
338
|
+
export class GuidedDiagnostics {
|
|
339
|
+
storageRoot;
|
|
340
|
+
now;
|
|
341
|
+
supportArchiveKey;
|
|
342
|
+
pendingSupportArchive;
|
|
343
|
+
uiEventWrites = Promise.resolve();
|
|
344
|
+
pendingUiEvents = 0;
|
|
345
|
+
uiEventsClosing = false;
|
|
346
|
+
constructor(storageRoot, now = Date.now, supportArchiveKey = SUPPORT_ARCHIVE_KEY) {
|
|
347
|
+
this.storageRoot = storageRoot;
|
|
348
|
+
this.now = now;
|
|
349
|
+
this.supportArchiveKey = supportArchiveKey;
|
|
350
|
+
}
|
|
351
|
+
async authorize(profile, reproductionMode) {
|
|
352
|
+
if (!isDiagnosticsProfile(profile)) {
|
|
353
|
+
throw new Error('Unknown diagnostics profile');
|
|
354
|
+
}
|
|
355
|
+
if (!isDiagnosticsReproductionMode(reproductionMode)) {
|
|
356
|
+
throw new Error('Unknown diagnostics reproduction mode');
|
|
357
|
+
}
|
|
358
|
+
this.uiEventsClosing = true;
|
|
359
|
+
try {
|
|
360
|
+
await this.uiEventWrites;
|
|
361
|
+
const authorizedAt = this.now();
|
|
362
|
+
const session = {
|
|
363
|
+
version: 1,
|
|
364
|
+
supportCaseId: `support-${randomUUID()}`,
|
|
365
|
+
profile,
|
|
366
|
+
reproductionMode,
|
|
367
|
+
authorizedAt: new Date(authorizedAt).toISOString(),
|
|
368
|
+
expiresAt: new Date(authorizedAt + DEBUG_AUTHORIZATION_MS).toISOString(),
|
|
369
|
+
};
|
|
370
|
+
await this.writeSession(session);
|
|
371
|
+
this.pendingSupportArchive = undefined;
|
|
372
|
+
return await this.project(session);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
this.uiEventsClosing = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async status() {
|
|
379
|
+
return this.project(readDiagnosticsSession(this.storageRoot));
|
|
380
|
+
}
|
|
381
|
+
async startReproduction() {
|
|
382
|
+
const session = this.requireAuthorized();
|
|
383
|
+
if (!session.reproductionStartedAt || session.reproductionEndedAt) {
|
|
384
|
+
if (session.reproductionEndedAt) {
|
|
385
|
+
session.supportCaseId = `support-${randomUUID()}`;
|
|
386
|
+
this.pendingSupportArchive = undefined;
|
|
387
|
+
}
|
|
388
|
+
if (session.profile === 'dashboard-ui') {
|
|
389
|
+
await this.uiEventWrites;
|
|
390
|
+
this.uiEventsClosing = false;
|
|
391
|
+
}
|
|
392
|
+
session.reproductionStartedAt = new Date(this.now()).toISOString();
|
|
393
|
+
delete session.reproductionEndedAt;
|
|
394
|
+
await this.writeSession(session);
|
|
395
|
+
}
|
|
396
|
+
await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
|
|
397
|
+
return this.project(session);
|
|
398
|
+
}
|
|
399
|
+
async endReproduction() {
|
|
400
|
+
let session = this.requireAuthorized();
|
|
401
|
+
if (!session.reproductionStartedAt) {
|
|
402
|
+
throw new Error('Reproduction has not started');
|
|
403
|
+
}
|
|
404
|
+
const closesUiEvents = session.profile === 'dashboard-ui';
|
|
405
|
+
if (closesUiEvents) {
|
|
406
|
+
this.uiEventsClosing = true;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
if (closesUiEvents) {
|
|
410
|
+
await this.uiEventWrites;
|
|
411
|
+
session = this.requireAuthorized();
|
|
412
|
+
}
|
|
413
|
+
if (!session.reproductionEndedAt) {
|
|
414
|
+
session.reproductionEndedAt = new Date(this.now()).toISOString();
|
|
415
|
+
await this.writeSession(session);
|
|
416
|
+
}
|
|
417
|
+
await this.ensureMarker(session, 'reproduction-ended', session.reproductionEndedAt);
|
|
418
|
+
return await this.project(session);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (closesUiEvents)
|
|
422
|
+
this.uiEventsClosing = false;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async recordUiEvent(event) {
|
|
427
|
+
if (!isDiagnosticsUiEvent(event)) {
|
|
428
|
+
throw new Error('Unknown diagnostics UI event');
|
|
429
|
+
}
|
|
430
|
+
if (this.uiEventsClosing) {
|
|
431
|
+
throw new Error('Diagnostics UI events are closing');
|
|
432
|
+
}
|
|
433
|
+
if (this.pendingUiEvents >= MAX_PENDING_UI_EVENTS) {
|
|
434
|
+
throw new Error('Diagnostics UI event queue is full');
|
|
435
|
+
}
|
|
436
|
+
this.pendingUiEvents += 1;
|
|
437
|
+
const write = this.uiEventWrites.then(async () => {
|
|
438
|
+
const session = this.requireAuthorized();
|
|
439
|
+
if (session.profile !== 'dashboard-ui' || !session.reproductionStartedAt || session.reproductionEndedAt) {
|
|
440
|
+
throw new Error('An active dashboard reproduction is required');
|
|
441
|
+
}
|
|
442
|
+
const directory = join(this.storageRoot, DIAGNOSTICS_DIRECTORY);
|
|
443
|
+
const path = join(directory, UI_EVENTS_FILE);
|
|
444
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
445
|
+
await chmod(directory, 0o700);
|
|
446
|
+
const record = `${JSON.stringify({
|
|
447
|
+
version: 1,
|
|
448
|
+
supportCaseId: session.supportCaseId,
|
|
449
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
450
|
+
event,
|
|
451
|
+
})}\n`;
|
|
452
|
+
let currentBytes = 0;
|
|
453
|
+
try {
|
|
454
|
+
currentBytes = (await stat(path)).size;
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (error.code !== 'ENOENT')
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
if (currentBytes + Buffer.byteLength(record) > MAX_UI_EVENTS_BYTES) {
|
|
461
|
+
const retained = await this.readFileTail(path, MAX_UI_EVENTS_BYTES - Buffer.byteLength(record));
|
|
462
|
+
await writeFile(path, `${retained}${record}`, { encoding: 'utf8', mode: 0o600 });
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
await appendFile(path, record, { encoding: 'utf8', mode: 0o600 });
|
|
466
|
+
}
|
|
467
|
+
await chmod(path, 0o600);
|
|
468
|
+
});
|
|
469
|
+
this.uiEventWrites = write
|
|
470
|
+
.catch(() => undefined)
|
|
471
|
+
.finally(() => {
|
|
472
|
+
this.pendingUiEvents -= 1;
|
|
473
|
+
});
|
|
474
|
+
return write;
|
|
475
|
+
}
|
|
476
|
+
/** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
|
|
477
|
+
async reviewSupportArchive() {
|
|
478
|
+
await this.uiEventWrites;
|
|
479
|
+
const session = readDiagnosticsSession(this.storageRoot);
|
|
480
|
+
if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
|
|
481
|
+
throw new Error('A completed reproduction is required before archive review');
|
|
482
|
+
}
|
|
483
|
+
const collected = await this.collectSupportEvidence(session);
|
|
484
|
+
const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
|
|
485
|
+
const selected = DIAGNOSTICS_PROFILES[session.profile];
|
|
486
|
+
const createdAt = this.now();
|
|
487
|
+
const manifest = {
|
|
488
|
+
version: 2,
|
|
489
|
+
archiveFormat: 'homebridge-eufy-support-archive',
|
|
490
|
+
keyId: this.supportArchiveKey.keyId,
|
|
491
|
+
supportCaseId: session.supportCaseId,
|
|
492
|
+
profile: session.profile,
|
|
493
|
+
reproductionMode: session.reproductionMode,
|
|
494
|
+
createdAt: new Date(createdAt).toISOString(),
|
|
495
|
+
archiveExpiresAt: new Date(createdAt + SUPPORT_ARCHIVE_RETENTION_MS).toISOString(),
|
|
496
|
+
reproductionStartedAt: session.reproductionStartedAt,
|
|
497
|
+
reproductionEndedAt: session.reproductionEndedAt,
|
|
498
|
+
evidence: [
|
|
499
|
+
...collected.map(({ evidence, privacyClass, contentType, content, truncated, retainedFrom, fields }) => ({
|
|
500
|
+
evidence,
|
|
501
|
+
privacyClass,
|
|
502
|
+
status: 'included',
|
|
503
|
+
contentType,
|
|
504
|
+
bytes: Buffer.byteLength(content),
|
|
505
|
+
fields,
|
|
506
|
+
...(truncated ? { truncated: true } : {}),
|
|
507
|
+
...(retainedFrom === undefined ? {} : { retainedFrom }),
|
|
508
|
+
...(retainedFrom !== undefined && Date.parse(retainedFrom) > reproductionStartedAt
|
|
509
|
+
? { coversReproduction: false }
|
|
510
|
+
: {}),
|
|
511
|
+
})),
|
|
512
|
+
...missingEvidenceClasses(selected, collected).map((evidence) => ({
|
|
513
|
+
evidence,
|
|
514
|
+
privacyClass: 'diagnostic',
|
|
515
|
+
status: 'missing',
|
|
516
|
+
missingReason: 'no-allowlisted-record-observed',
|
|
517
|
+
})),
|
|
518
|
+
],
|
|
519
|
+
excludedClasses: SUPPORT_ARCHIVE_EXCLUDED_CLASSES,
|
|
520
|
+
};
|
|
521
|
+
const reviewId = randomUUID();
|
|
522
|
+
this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
|
|
523
|
+
return { reviewId, manifest };
|
|
524
|
+
}
|
|
525
|
+
/** Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk. */
|
|
526
|
+
async exportSupportArchive(reviewId) {
|
|
527
|
+
const pending = this.pendingSupportArchive;
|
|
528
|
+
if (!pending || pending.reviewId !== reviewId) {
|
|
529
|
+
throw new Error('Support archive manifest review is missing or stale');
|
|
530
|
+
}
|
|
531
|
+
this.pendingSupportArchive = undefined;
|
|
532
|
+
if (Date.parse(pending.manifest.archiveExpiresAt) <= this.now()) {
|
|
533
|
+
throw new Error('Support archive manifest review is missing or stale');
|
|
534
|
+
}
|
|
535
|
+
const keyFingerprint = createHash('sha256').update(this.supportArchiveKey.publicKey.trim()).digest('hex');
|
|
536
|
+
if (keyFingerprint !== this.supportArchiveKey.sha256) {
|
|
537
|
+
throw new Error('Support archive key integrity check failed');
|
|
538
|
+
}
|
|
539
|
+
const compressed = await gzip(Buffer.from(JSON.stringify({
|
|
540
|
+
manifest: pending.manifest,
|
|
541
|
+
// Coverage is manifest metadata; the payload row is the content it describes.
|
|
542
|
+
evidence: pending.evidence.map(({ retainedFrom: _retainedFrom, ...row }) => row),
|
|
543
|
+
}), 'utf8'));
|
|
544
|
+
const contentKey = randomBytes(32);
|
|
545
|
+
try {
|
|
546
|
+
const iv = randomBytes(12);
|
|
547
|
+
const cipher = createCipheriv('aes-256-gcm', contentKey, iv);
|
|
548
|
+
const metadata = {
|
|
549
|
+
format: 'homebridge-eufy-support-archive',
|
|
550
|
+
version: 1,
|
|
551
|
+
keyId: this.supportArchiveKey.keyId,
|
|
552
|
+
keyWrapAlgorithm: 'RSA-OAEP-SHA256',
|
|
553
|
+
contentAlgorithm: 'AES-256-GCM',
|
|
554
|
+
contentEncoding: 'gzip+json',
|
|
555
|
+
};
|
|
556
|
+
const authenticatedMetadata = Buffer.from(JSON.stringify(metadata), 'utf8');
|
|
557
|
+
cipher.setAAD(authenticatedMetadata);
|
|
558
|
+
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
559
|
+
const wrappedKey = publicEncrypt({
|
|
560
|
+
key: this.supportArchiveKey.publicKey,
|
|
561
|
+
oaepHash: 'sha256',
|
|
562
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
563
|
+
}, contentKey);
|
|
564
|
+
const envelope = {
|
|
565
|
+
...metadata,
|
|
566
|
+
wrappedKey: wrappedKey.toString('base64'),
|
|
567
|
+
iv: iv.toString('base64'),
|
|
568
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
569
|
+
ciphertext: ciphertext.toString('base64'),
|
|
570
|
+
};
|
|
571
|
+
return {
|
|
572
|
+
filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
|
|
573
|
+
mediaType: 'application/gzip',
|
|
574
|
+
archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
contentKey.fill(0);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async collectSupportEvidence(session) {
|
|
582
|
+
const ffmpeg = readFfmpegEnvironment(this.storageRoot);
|
|
583
|
+
const evidence = [
|
|
584
|
+
{
|
|
585
|
+
evidence: 'environment',
|
|
586
|
+
privacyClass: 'operational',
|
|
587
|
+
contentType: 'application/json',
|
|
588
|
+
content: `${JSON.stringify({ version: 2, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, platform: process.platform, arch: process.arch, ...(ffmpeg ? { ffmpeg } : {}) })}\n`,
|
|
589
|
+
/**
|
|
590
|
+
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
591
|
+
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
592
|
+
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
593
|
+
*/
|
|
594
|
+
fields: [
|
|
595
|
+
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
596
|
+
field,
|
|
597
|
+
privacyClass: 'operational',
|
|
598
|
+
})),
|
|
599
|
+
...(ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
];
|
|
603
|
+
const markers = await this.readReproductionMarkers(session.supportCaseId);
|
|
604
|
+
if (markers) {
|
|
605
|
+
evidence.push({
|
|
606
|
+
evidence: 'reproduction-markers',
|
|
607
|
+
privacyClass: 'operational',
|
|
608
|
+
contentType: 'application/x-ndjson',
|
|
609
|
+
content: markers,
|
|
610
|
+
fields: [
|
|
611
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
612
|
+
{ field: 'supportCaseId', privacyClass: 'pseudonymous' },
|
|
613
|
+
{ field: 'event', privacyClass: 'operational' },
|
|
614
|
+
{ field: 'timestamp', privacyClass: 'operational' },
|
|
615
|
+
],
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
const uiEvents = await this.readUiEvents(session);
|
|
619
|
+
if (uiEvents) {
|
|
620
|
+
evidence.push({
|
|
621
|
+
evidence: 'ui-log',
|
|
622
|
+
privacyClass: 'diagnostic',
|
|
623
|
+
contentType: 'application/x-ndjson',
|
|
624
|
+
content: uiEvents,
|
|
625
|
+
fields: [
|
|
626
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
627
|
+
{ field: 'supportCaseId', privacyClass: 'pseudonymous' },
|
|
628
|
+
{ field: 'timestamp', privacyClass: 'operational' },
|
|
629
|
+
{ field: 'event', privacyClass: 'diagnostic' },
|
|
630
|
+
],
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
const log = await this.readReproductionLog(session.reproductionStartedAt, session.reproductionEndedAt);
|
|
634
|
+
for (const selected of DIAGNOSTICS_PROFILES[session.profile]) {
|
|
635
|
+
const carried = log.get(selected);
|
|
636
|
+
if (!carried)
|
|
637
|
+
continue;
|
|
638
|
+
const content = carried.records.map((record) => JSON.stringify(record)).join('\n');
|
|
639
|
+
if (!content)
|
|
640
|
+
continue;
|
|
641
|
+
evidence.push({
|
|
642
|
+
evidence: selected,
|
|
643
|
+
privacyClass: 'diagnostic',
|
|
644
|
+
contentType: 'application/x-ndjson',
|
|
645
|
+
content: `${content}\n`,
|
|
646
|
+
...(carried.truncated ? { truncated: true } : {}),
|
|
647
|
+
...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
|
|
648
|
+
fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
|
|
649
|
+
field,
|
|
650
|
+
privacyClass: field === 'accessoryAliases' ? 'pseudonymous' : field === 'timestamp' ? 'operational' : 'diagnostic',
|
|
651
|
+
})),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return evidence;
|
|
655
|
+
}
|
|
656
|
+
async readUiEvents(session) {
|
|
657
|
+
try {
|
|
658
|
+
const path = join(this.storageRoot, DIAGNOSTICS_DIRECTORY, UI_EVENTS_FILE);
|
|
659
|
+
const startedAt = Date.parse(session.reproductionStartedAt);
|
|
660
|
+
const endedAt = Date.parse(session.reproductionEndedAt);
|
|
661
|
+
const records = (await this.readFileTail(path, MAX_UI_EVENTS_BYTES))
|
|
662
|
+
.trim()
|
|
663
|
+
.split('\n')
|
|
664
|
+
.flatMap((line) => {
|
|
665
|
+
try {
|
|
666
|
+
const candidate = JSON.parse(line);
|
|
667
|
+
const timestamp = typeof candidate.timestamp === 'string' ? Date.parse(candidate.timestamp) : Number.NaN;
|
|
668
|
+
if (Object.keys(candidate).sort().join(',') !== 'event,supportCaseId,timestamp,version' ||
|
|
669
|
+
candidate.version !== 1 ||
|
|
670
|
+
candidate.supportCaseId !== session.supportCaseId ||
|
|
671
|
+
!isDiagnosticsUiEvent(candidate.event) ||
|
|
672
|
+
!Number.isFinite(timestamp) ||
|
|
673
|
+
timestamp < startedAt ||
|
|
674
|
+
timestamp > endedAt) {
|
|
675
|
+
return [];
|
|
676
|
+
}
|
|
677
|
+
return [
|
|
678
|
+
JSON.stringify({
|
|
679
|
+
version: 1,
|
|
680
|
+
supportCaseId: candidate.supportCaseId,
|
|
681
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
682
|
+
event: candidate.event,
|
|
683
|
+
}),
|
|
684
|
+
];
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
return records.length ? `${records.join('\n')}\n` : '';
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error.code === 'ENOENT')
|
|
694
|
+
return '';
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async readReproductionMarkers(supportCaseId) {
|
|
699
|
+
try {
|
|
700
|
+
const path = join(this.storageRoot, DIAGNOSTICS_DIRECTORY, REPRODUCTION_MARKERS_FILE);
|
|
701
|
+
const lines = (await this.readFileTail(path, MAX_SUPPORT_ARCHIVE_MARKER_BYTES))
|
|
702
|
+
.trim()
|
|
703
|
+
.split('\n')
|
|
704
|
+
.filter((line) => {
|
|
705
|
+
try {
|
|
706
|
+
return JSON.parse(line).supportCaseId === supportCaseId;
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
return lines.length ? `${lines.join('\n')}\n` : '';
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
if (error.code === 'ENOENT')
|
|
716
|
+
return '';
|
|
717
|
+
throw error;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The records of each log evidence class inside the reproduction interval, budgeted per class.
|
|
722
|
+
*
|
|
723
|
+
* Fills newest-first, so a class that exhausts its budget loses its oldest end and `retainedFrom` states
|
|
724
|
+
* where it then begins. Each class is budgeted alone, so the noisiest one cannot decide how far back another
|
|
725
|
+
* reaches.
|
|
726
|
+
*/
|
|
727
|
+
async readReproductionLog(startedAt, endedAt) {
|
|
728
|
+
const directory = join(this.storageRoot, LOG_DIRECTORY);
|
|
729
|
+
const paths = [
|
|
730
|
+
join(directory, LOG_FILE),
|
|
731
|
+
...Array.from({ length: LOG_ROTATIONS }, (_, index) => join(directory, `${LOG_FILE}.${index + 1}.gz`)),
|
|
732
|
+
];
|
|
733
|
+
const started = Date.parse(startedAt);
|
|
734
|
+
const ended = Date.parse(endedAt);
|
|
735
|
+
const carried = new Map(['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'].map((evidence) => [
|
|
736
|
+
evidence,
|
|
737
|
+
{ records: [], bytes: 0, truncated: false },
|
|
738
|
+
]));
|
|
739
|
+
for (const path of paths) {
|
|
740
|
+
if ([...carried.values()].every(({ truncated }) => truncated))
|
|
741
|
+
break;
|
|
742
|
+
try {
|
|
743
|
+
const file = await stat(path);
|
|
744
|
+
if (file.size > MAX_CURRENT_LOG_BYTES) {
|
|
745
|
+
for (const state of carried.values())
|
|
746
|
+
state.truncated = true;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const content = path.endsWith('.gz')
|
|
750
|
+
? (await gunzip(await readFile(path), {
|
|
751
|
+
maxOutputLength: MAX_CURRENT_LOG_BYTES + MAX_LOG_RECORD_BYTES,
|
|
752
|
+
})).toString('utf8')
|
|
753
|
+
: await readFile(path, 'utf8');
|
|
754
|
+
const lines = content.trim().split('\n');
|
|
755
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
756
|
+
const line = lines[index];
|
|
757
|
+
let record;
|
|
758
|
+
try {
|
|
759
|
+
record = JSON.parse(line);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const timestamp = typeof record.timestamp === 'string' ? Date.parse(record.timestamp) : Number.NaN;
|
|
765
|
+
if (timestamp < started || timestamp > ended)
|
|
766
|
+
continue;
|
|
767
|
+
const state = carried.get(evidenceClassOf(record));
|
|
768
|
+
if (state.truncated)
|
|
769
|
+
continue;
|
|
770
|
+
const bytes = Buffer.byteLength(line) + 1;
|
|
771
|
+
if (state.bytes + bytes > MAX_SUPPORT_ARCHIVE_LOG_BYTES) {
|
|
772
|
+
state.truncated = true;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
state.bytes += bytes;
|
|
776
|
+
state.records.push(record);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
if (error.code !== 'ENOENT')
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return new Map([...carried].map(([evidence, { records, truncated }]) => {
|
|
785
|
+
const ordered = records.reverse();
|
|
786
|
+
const oldest = ordered.find(({ timestamp }) => typeof timestamp === 'string')?.timestamp;
|
|
787
|
+
return [
|
|
788
|
+
evidence,
|
|
789
|
+
{ records: ordered, truncated, ...(truncated && typeof oldest === 'string' ? { retainedFrom: oldest } : {}) },
|
|
790
|
+
];
|
|
791
|
+
}));
|
|
792
|
+
}
|
|
793
|
+
async readFileTail(path, maximumBytes) {
|
|
794
|
+
const file = await open(path, 'r');
|
|
795
|
+
try {
|
|
796
|
+
const size = (await file.stat()).size;
|
|
797
|
+
const bytes = Math.min(size, maximumBytes);
|
|
798
|
+
const buffer = Buffer.alloc(bytes);
|
|
799
|
+
await file.read(buffer, 0, bytes, size - bytes);
|
|
800
|
+
const content = buffer.toString('utf8');
|
|
801
|
+
return size > maximumBytes ? content.slice(content.indexOf('\n') + 1) : content;
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
await file.close();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
requireAuthorized() {
|
|
808
|
+
const session = activeDiagnosticsSession(this.storageRoot, this.now());
|
|
809
|
+
if (!session)
|
|
810
|
+
throw new Error('Diagnostics authorization is inactive or expired');
|
|
811
|
+
return session;
|
|
812
|
+
}
|
|
813
|
+
async project(session) {
|
|
814
|
+
if (!session) {
|
|
815
|
+
return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
|
|
816
|
+
}
|
|
817
|
+
const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
|
|
818
|
+
const collected = session.reproductionStartedAt && session.reproductionEndedAt
|
|
819
|
+
? await this.collectSupportEvidence(session)
|
|
820
|
+
: undefined;
|
|
821
|
+
const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
|
|
822
|
+
const expired = Date.parse(session.expiresAt) <= this.now();
|
|
823
|
+
const status = expired
|
|
824
|
+
? 'expired'
|
|
825
|
+
: session.reproductionEndedAt
|
|
826
|
+
? 'complete'
|
|
827
|
+
: session.reproductionStartedAt
|
|
828
|
+
? 'reproducing'
|
|
829
|
+
: 'authorized';
|
|
830
|
+
const issueBody = [
|
|
831
|
+
`Support case: ${session.supportCaseId}`,
|
|
832
|
+
`Profile: ${session.profile}`,
|
|
833
|
+
`Reproduction mode: ${session.reproductionMode}`,
|
|
834
|
+
`Missing evidence: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
835
|
+
].join('\n');
|
|
836
|
+
return {
|
|
837
|
+
status,
|
|
838
|
+
supportCaseId: session.supportCaseId,
|
|
839
|
+
profile: session.profile,
|
|
840
|
+
reproductionMode: session.reproductionMode,
|
|
841
|
+
selectedEvidence,
|
|
842
|
+
missingEvidence,
|
|
843
|
+
authorizedAt: session.authorizedAt,
|
|
844
|
+
expiresAt: session.expiresAt,
|
|
845
|
+
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
846
|
+
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
847
|
+
partialExportAvailable: Boolean(session.reproductionEndedAt),
|
|
848
|
+
issueUrl: `https://github.com/homebridge-plugins/homebridge-eufy/issues/new?body=${encodeURIComponent(issueBody)}`,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Replaces the persisted session, and drops the `diagnostics/evidence` tree with it.
|
|
853
|
+
*
|
|
854
|
+
* Nothing reads that tree: a session's completeness is one read of the evidence an archive would carry, so
|
|
855
|
+
* the only diagnostics state that outlives a session is the rotating log and the persisted session itself.
|
|
856
|
+
*/
|
|
857
|
+
async writeSession(session) {
|
|
858
|
+
const directory = join(this.storageRoot, DIAGNOSTICS_DIRECTORY);
|
|
859
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
860
|
+
await chmod(directory, 0o700);
|
|
861
|
+
await rm(join(directory, 'evidence'), { force: true, recursive: true });
|
|
862
|
+
await this.replaceOwnedFile(diagnosticsSessionPath(this.storageRoot), `${JSON.stringify(session)}\n`);
|
|
863
|
+
}
|
|
864
|
+
/** Replaces one file this module owns in a single step, readable only by the account Homebridge runs as. */
|
|
865
|
+
async replaceOwnedFile(path, content) {
|
|
866
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
867
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
868
|
+
await chmod(temporary, 0o600);
|
|
869
|
+
await rename(temporary, path);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Records one bounded reproduction edge, keeping the markers file to the current support case.
|
|
873
|
+
*
|
|
874
|
+
* The interval a maintainer receives is the one being reproduced now, so an earlier case's markers are
|
|
875
|
+
* dropped here rather than accumulating one durable record per support session for the life of the install.
|
|
876
|
+
* Recording the same edge twice is a no-op, which is what lets a restart re-record an interval it already had.
|
|
877
|
+
*/
|
|
878
|
+
async ensureMarker(session, event, timestamp) {
|
|
879
|
+
const directory = join(this.storageRoot, DIAGNOSTICS_DIRECTORY);
|
|
880
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
881
|
+
const retained = await this.readReproductionMarkers(session.supportCaseId);
|
|
882
|
+
const marker = `${JSON.stringify({ version: 1, supportCaseId: session.supportCaseId, event, timestamp })}\n`;
|
|
883
|
+
if (retained.includes(marker)) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
await this.replaceOwnedFile(join(directory, REPRODUCTION_MARKERS_FILE), `${retained}${marker}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function formatMessage(catalog, key, parameters = {}) {
|
|
890
|
+
const template = catalog[key] ?? EN_MESSAGES[key] ?? key;
|
|
891
|
+
return template
|
|
892
|
+
.replace(/\{([a-zA-Z0-9]+)\}/g, (placeholder, name) => parameters[name] === undefined ? placeholder : String(parameters[name]))
|
|
893
|
+
.replace(/\s+/g, ' ')
|
|
894
|
+
.trim();
|
|
895
|
+
}
|
|
896
|
+
function localize(target, key, parameters = {}) {
|
|
897
|
+
const translator = target.localize;
|
|
898
|
+
return translator?.(key, parameters) ?? formatMessage(EN_MESSAGES, key, parameters);
|
|
899
|
+
}
|
|
900
|
+
class JsonLineLog {
|
|
901
|
+
onError;
|
|
902
|
+
now;
|
|
903
|
+
directory;
|
|
904
|
+
path;
|
|
905
|
+
pending = Promise.resolve();
|
|
906
|
+
queuedBytes = 0;
|
|
907
|
+
droppedRecords = 0;
|
|
908
|
+
droppedAt;
|
|
909
|
+
constructor(storageRoot, onError, now) {
|
|
910
|
+
this.onError = onError;
|
|
911
|
+
this.now = now;
|
|
912
|
+
this.directory = join(storageRoot, LOG_DIRECTORY);
|
|
913
|
+
this.path = join(this.directory, LOG_FILE);
|
|
914
|
+
}
|
|
915
|
+
write(message) {
|
|
916
|
+
const payload = sanitizeStructuredEvent(message);
|
|
917
|
+
if (!payload) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const record = this.serialize(payload);
|
|
921
|
+
const bytes = Buffer.byteLength(record);
|
|
922
|
+
if (bytes > MAX_LOG_RECORD_BYTES || this.queuedBytes + bytes > MAX_QUEUED_LOG_BYTES) {
|
|
923
|
+
this.droppedAt ??= new Date().toISOString();
|
|
924
|
+
this.droppedRecords += 1;
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const droppedRecords = this.droppedRecords;
|
|
928
|
+
const droppedAt = this.droppedAt;
|
|
929
|
+
this.droppedRecords = 0;
|
|
930
|
+
this.droppedAt = undefined;
|
|
931
|
+
this.queuedBytes += bytes;
|
|
932
|
+
this.pending = this.pending
|
|
933
|
+
.then(async () => {
|
|
934
|
+
if (droppedRecords > 0) {
|
|
935
|
+
await this.append(this.serialize({
|
|
936
|
+
scope: 'diagnostics',
|
|
937
|
+
level: 'warn',
|
|
938
|
+
event: 'records-dropped',
|
|
939
|
+
droppedRecords,
|
|
940
|
+
}, droppedAt));
|
|
941
|
+
}
|
|
942
|
+
await this.append(record);
|
|
943
|
+
})
|
|
944
|
+
.catch(() => this.onError())
|
|
945
|
+
.finally(() => {
|
|
946
|
+
this.queuedBytes -= bytes;
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
flush() {
|
|
950
|
+
const droppedRecords = this.droppedRecords;
|
|
951
|
+
const droppedAt = this.droppedAt;
|
|
952
|
+
this.droppedRecords = 0;
|
|
953
|
+
this.droppedAt = undefined;
|
|
954
|
+
if (droppedRecords > 0) {
|
|
955
|
+
this.pending = this.pending
|
|
956
|
+
.then(() => this.append(this.serialize({
|
|
957
|
+
scope: 'diagnostics',
|
|
958
|
+
level: 'warn',
|
|
959
|
+
event: 'records-dropped',
|
|
960
|
+
droppedRecords,
|
|
961
|
+
}, droppedAt)))
|
|
962
|
+
.catch(() => this.onError());
|
|
963
|
+
}
|
|
964
|
+
return this.pending;
|
|
965
|
+
}
|
|
966
|
+
serialize(payload, timestamp = new Date(this.now()).toISOString()) {
|
|
967
|
+
return `${JSON.stringify({ ...payload, timestamp })}\n`;
|
|
968
|
+
}
|
|
969
|
+
async append(record) {
|
|
970
|
+
await mkdir(this.directory, { mode: 0o700, recursive: true });
|
|
971
|
+
await chmod(this.directory, 0o700);
|
|
972
|
+
const bytes = Buffer.byteLength(record);
|
|
973
|
+
await this.prepare(bytes);
|
|
974
|
+
try {
|
|
975
|
+
await chmod(this.path, 0o600);
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
if (error.code !== 'ENOENT') {
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
await appendFile(this.path, record, { encoding: 'utf8', flag: 'a', mode: 0o600 });
|
|
983
|
+
await chmod(this.path, 0o600);
|
|
984
|
+
}
|
|
985
|
+
async prepare(recordBytes) {
|
|
986
|
+
try {
|
|
987
|
+
const current = await stat(this.path);
|
|
988
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
989
|
+
const currentDay = current.mtime.toISOString().slice(0, 10);
|
|
990
|
+
if (currentDay !== today || current.size + recordBytes > MAX_CURRENT_LOG_BYTES) {
|
|
991
|
+
await this.rotate();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
if (error.code !== 'ENOENT') {
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
async rotate() {
|
|
1001
|
+
await rm(`${this.path}.${LOG_ROTATIONS}.gz`, { force: true });
|
|
1002
|
+
for (let index = LOG_ROTATIONS - 1; index >= 1; index -= 1) {
|
|
1003
|
+
try {
|
|
1004
|
+
const target = `${this.path}.${index + 1}.gz`;
|
|
1005
|
+
await rename(`${this.path}.${index}.gz`, target);
|
|
1006
|
+
await chmod(target, 0o600);
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
if (error.code !== 'ENOENT') {
|
|
1010
|
+
throw error;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const compressed = await gzip(await readFile(this.path));
|
|
1015
|
+
await writeFile(`${this.path}.1.gz`, compressed, { flag: 'w', mode: 0o600 });
|
|
1016
|
+
await chmod(`${this.path}.1.gz`, 0o600);
|
|
1017
|
+
await rm(this.path, { force: true });
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Routes human messages to Homebridge and structured events to the plugin JSONL log.
|
|
1022
|
+
* File writes preserve accepted order; records beyond the bounded pending queue are counted and reported.
|
|
1023
|
+
*/
|
|
1024
|
+
export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGES, now = Date.now) {
|
|
1025
|
+
let fileFailureReported = false;
|
|
1026
|
+
const reportFileFailure = () => {
|
|
1027
|
+
if (!fileFailureReported) {
|
|
1028
|
+
fileFailureReported = true;
|
|
1029
|
+
target.warn(`[diagnostic-log-write-failed] ${formatMessage(catalog, 'log.diagnosticFileFailed')}`);
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
const file = storageRoot ? new JsonLineLog(storageRoot, reportFileFailure, now) : undefined;
|
|
1033
|
+
const debug = file
|
|
1034
|
+
? (message) => {
|
|
1035
|
+
const event = sanitizeStructuredEvent(message);
|
|
1036
|
+
if (!event)
|
|
1037
|
+
return;
|
|
1038
|
+
const session = activeDiagnosticsSession(storageRoot, now());
|
|
1039
|
+
const verbose = VERBOSE_SCOPES.has(String(event.scope));
|
|
1040
|
+
if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
file.write(JSON.stringify(event));
|
|
1044
|
+
}
|
|
1045
|
+
: undefined;
|
|
1046
|
+
return {
|
|
1047
|
+
...(debug ? { debug } : {}),
|
|
1048
|
+
error: (message) => target.error(message),
|
|
1049
|
+
flush: () => file?.flush() ?? Promise.resolve(),
|
|
1050
|
+
info: (message) => target.info(message),
|
|
1051
|
+
localize: (key, parameters) => formatMessage(catalog, key, parameters),
|
|
1052
|
+
warn: (message) => target.warn(message),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
/** The class an SDK message belongs to, or nothing when its text matches none of them. */
|
|
1056
|
+
function classifySdkEvent(message) {
|
|
1057
|
+
const normalized = message.toLowerCase();
|
|
1058
|
+
if (normalized.includes('restored persisted session'))
|
|
1059
|
+
return 'session-restored';
|
|
1060
|
+
if (normalized.includes('retrying') || normalized.includes('reconnecting'))
|
|
1061
|
+
return 'connection-retrying';
|
|
1062
|
+
if (normalized.includes('close') || normalized.includes('disconnect'))
|
|
1063
|
+
return 'connection-closed';
|
|
1064
|
+
if (normalized.includes('in use again') || normalized.includes('idle-detach cancelled'))
|
|
1065
|
+
return 'session-resumed';
|
|
1066
|
+
if (normalized.includes('connecting'))
|
|
1067
|
+
return 'session-connecting';
|
|
1068
|
+
if (normalized.includes('connected') || normalized.includes('logged in'))
|
|
1069
|
+
return 'connection-opened';
|
|
1070
|
+
if (normalized.includes('idle'))
|
|
1071
|
+
return 'session-idle';
|
|
1072
|
+
if (normalized.includes('candidate failed'))
|
|
1073
|
+
return 'snapshot-cache-warning';
|
|
1074
|
+
if (normalized.includes('wire value is not numeric') ||
|
|
1075
|
+
normalized.includes('unknown codec') ||
|
|
1076
|
+
normalized.includes('malformed')) {
|
|
1077
|
+
return 'observation-invalid';
|
|
1078
|
+
}
|
|
1079
|
+
if (normalized.includes('unhandled payload'))
|
|
1080
|
+
return 'protocol-unhandled';
|
|
1081
|
+
if (normalized.includes('sendsetpayload') ||
|
|
1082
|
+
normalized.includes('requestimage') ||
|
|
1083
|
+
normalized.includes('querydatabase')) {
|
|
1084
|
+
return 'protocol-command';
|
|
1085
|
+
}
|
|
1086
|
+
if ((normalized.includes('talkback') || normalized.includes('live media') || normalized.includes('live stream')) &&
|
|
1087
|
+
(normalized.includes('failed') || normalized.includes('error'))) {
|
|
1088
|
+
return 'media-error';
|
|
1089
|
+
}
|
|
1090
|
+
if (normalized.includes('upstream error') ||
|
|
1091
|
+
normalized.includes('send err') ||
|
|
1092
|
+
normalized.includes('connection error')) {
|
|
1093
|
+
return 'transport-error';
|
|
1094
|
+
}
|
|
1095
|
+
if (normalized.includes('failed') || normalized.includes('error'))
|
|
1096
|
+
return 'operation-failed';
|
|
1097
|
+
if (normalized.includes('talkback') ||
|
|
1098
|
+
normalized.includes('live media') ||
|
|
1099
|
+
normalized.includes('startlivemedia') ||
|
|
1100
|
+
normalized.includes('stoplivemedia')) {
|
|
1101
|
+
return 'media-warning';
|
|
1102
|
+
}
|
|
1103
|
+
if (normalized.includes('[eufy]'))
|
|
1104
|
+
return 'client-warning';
|
|
1105
|
+
return undefined;
|
|
1106
|
+
}
|
|
1107
|
+
/** A non-negative integer, or nothing — a byte count, a duration, a tally of accessories. */
|
|
1108
|
+
function nonNegativeInteger(value) {
|
|
1109
|
+
return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* A whole number within an inclusive range, or nothing — a data-type id, a sign code, an exit status, or a
|
|
1113
|
+
* negotiated geometry or frame rate a controller chose.
|
|
1114
|
+
*/
|
|
1115
|
+
function boundedInteger(value, max, min = 0) {
|
|
1116
|
+
const integer = nonNegativeInteger(value);
|
|
1117
|
+
return integer !== undefined && integer <= max && integer >= min ? integer : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* One of a fixed set of labels, or nothing.
|
|
1121
|
+
*
|
|
1122
|
+
* The only string form any value crossing this boundary is retained in: a label that is not on the list is
|
|
1123
|
+
* dropped rather than truncated or escaped, so no supplied text is ever carried through.
|
|
1124
|
+
*/
|
|
1125
|
+
function allowlistedLabel(value, allowed) {
|
|
1126
|
+
return allowed.includes(String(value)) ? String(value) : undefined;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The widest startup window a retained duration may state.
|
|
1130
|
+
*
|
|
1131
|
+
* A minute bounds every window the SDK actually uses — a level-2 grace, a warm-up deadline, a retry interval —
|
|
1132
|
+
* and a duration beyond it is a value this plugin did not expect rather than a measurement, so it is dropped
|
|
1133
|
+
* like any other field that fails its shape.
|
|
1134
|
+
*/
|
|
1135
|
+
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1136
|
+
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1137
|
+
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1138
|
+
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
1139
|
+
const retainedInteger = (field, max) => (candidate) => {
|
|
1140
|
+
const value = boundedInteger(candidate[field], max);
|
|
1141
|
+
return value === undefined ? undefined : { [field]: value };
|
|
1142
|
+
};
|
|
1143
|
+
/**
|
|
1144
|
+
* What each phase of the SDK's live trace vocabulary retains, keyed by the phase itself.
|
|
1145
|
+
*
|
|
1146
|
+
* Typed against the published `LiveTrace` union, so a phase this table does not name fails to compile rather
|
|
1147
|
+
* than being dropped to a content-free record at runtime.
|
|
1148
|
+
*
|
|
1149
|
+
* Each entry validates the fields its own phase declares, by name, and returns nothing when they are not the
|
|
1150
|
+
* shape claimed, because this reads a value that crossed a process boundary. A field is retained only as a
|
|
1151
|
+
* fixed label, a boolean, or a bounded integer; the SDK states that its traces carry no serial, address or
|
|
1152
|
+
* key material, and validating rather than copying is what keeps that true here whatever it sends.
|
|
1153
|
+
*
|
|
1154
|
+
* The field NAMES are not checked against the union, only the phases are — so a field the SDK renames within
|
|
1155
|
+
* an existing phase is dropped silently, and that phase keeps only what it still recognises.
|
|
1156
|
+
*/
|
|
1157
|
+
const LIVE_TRACE_PHASES = {
|
|
1158
|
+
'media-command': (c) => {
|
|
1159
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1160
|
+
const action = allowlistedLabel(c.action, ['keepalive', 'start']);
|
|
1161
|
+
return topology && action && typeof c.level2 === 'boolean' ? { topology, action, level2: c.level2 } : undefined;
|
|
1162
|
+
},
|
|
1163
|
+
'media-command-ack': retainedStart,
|
|
1164
|
+
'media-command-retry': retainedStart,
|
|
1165
|
+
'media-command-unacknowledged': retainedStart,
|
|
1166
|
+
'first-video-command': (c) => {
|
|
1167
|
+
const signCode = boundedInteger(c.signCode, 255);
|
|
1168
|
+
return signCode !== undefined && typeof c.accepted === 'boolean' ? { signCode, accepted: c.accepted } : undefined;
|
|
1169
|
+
},
|
|
1170
|
+
'first-video-unit': (c) => (typeof c.keyframe === 'boolean' ? { keyframe: c.keyframe } : undefined),
|
|
1171
|
+
'first-keyframe': () => ({}),
|
|
1172
|
+
'first-foreign-media-command': (c) => {
|
|
1173
|
+
const media = allowlistedLabel(c.media, ['audio', 'video']);
|
|
1174
|
+
return media ? { media } : undefined;
|
|
1175
|
+
},
|
|
1176
|
+
'video-decode-empty': retainedInteger('signCode', 255),
|
|
1177
|
+
'datagram-gap': retainedInteger('dataType', 3),
|
|
1178
|
+
'sequence-restart': retainedInteger('dataType', 3),
|
|
1179
|
+
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1180
|
+
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1181
|
+
'level2-absent': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1182
|
+
/**
|
|
1183
|
+
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1184
|
+
*
|
|
1185
|
+
* A duration, so it is retained as a bounded tally. Its bound is the warm-up window a start is given: a
|
|
1186
|
+
* silence longer than that belongs to a session nothing is waiting on.
|
|
1187
|
+
*/
|
|
1188
|
+
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1189
|
+
'media-command-unsent': (c) => {
|
|
1190
|
+
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1191
|
+
return reason ? { reason } : undefined;
|
|
1192
|
+
},
|
|
1193
|
+
warming: (c) => {
|
|
1194
|
+
const retryMs = boundedInteger(c.retryMs, MAX_STARTUP_WINDOW_MS);
|
|
1195
|
+
const deadlineMs = boundedInteger(c.deadlineMs, MAX_STARTUP_WINDOW_MS);
|
|
1196
|
+
return retryMs === undefined || deadlineMs === undefined ? undefined : { retryMs, deadlineMs };
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
function sanitizeSdkLiveStartTrace(value) {
|
|
1200
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1201
|
+
return undefined;
|
|
1202
|
+
const candidate = value;
|
|
1203
|
+
const phase = String(candidate.phase);
|
|
1204
|
+
if (!Object.hasOwn(LIVE_TRACE_PHASES, phase))
|
|
1205
|
+
return undefined;
|
|
1206
|
+
const fields = LIVE_TRACE_PHASES[phase](candidate);
|
|
1207
|
+
if (!fields)
|
|
1208
|
+
return undefined;
|
|
1209
|
+
const source = opaquePullHandle(candidate.source);
|
|
1210
|
+
return { phase, ...(source === undefined ? {} : { source }), ...fields };
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* The SDK's per-process handle for a pull, or nothing where the value is not one.
|
|
1214
|
+
*
|
|
1215
|
+
* Matched against the shape a handle has rather than searched for what a serial looks like: this record is
|
|
1216
|
+
* what a support archive keeps, and a redaction that depends on recognising a secret fails on the first
|
|
1217
|
+
* message shape nobody predicted. `pull-3` and `station-1:2` pass; anything else, a serial included, does not.
|
|
1218
|
+
*/
|
|
1219
|
+
function opaquePullHandle(value) {
|
|
1220
|
+
return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Adapts SDK protocol detail to bounded debug output without preserving supplied values.
|
|
1224
|
+
*
|
|
1225
|
+
* The SDK runs FFmpeg of its own for snapshot decoding and WebRTC containers and forwards that process's
|
|
1226
|
+
* stderr under an `[ffmpeg]` prefix. That is the same class of evidence as this plugin's own adaptation
|
|
1227
|
+
* output, so it is recorded as such, redacted line by line, rather than dropped as SDK chatter: a snapshot
|
|
1228
|
+
* that never decodes has no other account anywhere of why.
|
|
1229
|
+
*
|
|
1230
|
+
* Any other message is recorded only when {@link classifySdkEvent} names a class for it, because the class is
|
|
1231
|
+
* all a record carries.
|
|
1232
|
+
*/
|
|
1233
|
+
export function createSdkLogger(target) {
|
|
1234
|
+
if (!target?.debug) {
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
const format = (message, args) => {
|
|
1238
|
+
if (message === LIVE_TRACE_MESSAGE) {
|
|
1239
|
+
const trace = sanitizeSdkLiveStartTrace(args[0]);
|
|
1240
|
+
if (trace)
|
|
1241
|
+
return { scope: 'sdk', subsystem: 'p2p', event: 'live-start-trace', ...trace };
|
|
1242
|
+
}
|
|
1243
|
+
const requestedSubsystem = /^\[([a-z0-9-]+)(?:\s+[^\]]+)?\]/i.exec(message)?.[1]?.toLowerCase();
|
|
1244
|
+
if (requestedSubsystem === 'ffmpeg') {
|
|
1245
|
+
const stderr = message.slice(message.indexOf(']') + 1).split(/\r?\n/);
|
|
1246
|
+
return sanitizeAdaptationNotice({ role: 'sdk', event: 'output', stderr }, 'debug');
|
|
1247
|
+
}
|
|
1248
|
+
const event = classifySdkEvent(message);
|
|
1249
|
+
if (!event)
|
|
1250
|
+
return undefined;
|
|
1251
|
+
const subsystemAliases = {
|
|
1252
|
+
eufy: 'mega',
|
|
1253
|
+
fcm: 'push',
|
|
1254
|
+
live: 'p2p',
|
|
1255
|
+
session: 'p2p',
|
|
1256
|
+
smqtt: 'mqtt',
|
|
1257
|
+
'stored-snapshot-cache': 'device',
|
|
1258
|
+
};
|
|
1259
|
+
const aliasedSubsystem = requestedSubsystem ? subsystemAliases[requestedSubsystem] : undefined;
|
|
1260
|
+
const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
|
|
1261
|
+
const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
|
|
1262
|
+
if (value instanceof Error) {
|
|
1263
|
+
const errorType = ['Error', 'RangeError', 'SessionExpiredError', 'TypeError'].includes(value.name)
|
|
1264
|
+
? value.name
|
|
1265
|
+
: 'Error';
|
|
1266
|
+
return { errorType };
|
|
1267
|
+
}
|
|
1268
|
+
if (typeof value === 'string') {
|
|
1269
|
+
return { type: 'string', length: value.length };
|
|
1270
|
+
}
|
|
1271
|
+
if (value && typeof value === 'object') {
|
|
1272
|
+
return { type: 'object' };
|
|
1273
|
+
}
|
|
1274
|
+
return { type: typeof value };
|
|
1275
|
+
});
|
|
1276
|
+
return {
|
|
1277
|
+
scope: 'sdk',
|
|
1278
|
+
subsystem,
|
|
1279
|
+
event,
|
|
1280
|
+
...(details.length ? { details } : {}),
|
|
1281
|
+
...(args.length > MAX_SDK_DETAILS ? { detailsTruncated: true } : {}),
|
|
1282
|
+
};
|
|
1283
|
+
};
|
|
1284
|
+
const write = (level, message, args) => {
|
|
1285
|
+
const event = format(message, args);
|
|
1286
|
+
if (event) {
|
|
1287
|
+
target.debug(JSON.stringify({ ...event, level }));
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
return {
|
|
1291
|
+
debug: (message, ...args) => write('debug', message, args),
|
|
1292
|
+
info: (message, ...args) => write('info', message, args),
|
|
1293
|
+
warn: (message, ...args) => write('warn', message, args),
|
|
1294
|
+
error: (message, ...args) => write('error', message, args),
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
const RUNTIME_STATES = [
|
|
1298
|
+
'stopped',
|
|
1299
|
+
'acquiring-ownership',
|
|
1300
|
+
'starting',
|
|
1301
|
+
'ready',
|
|
1302
|
+
'degraded',
|
|
1303
|
+
'authentication-required',
|
|
1304
|
+
'owner-conflict',
|
|
1305
|
+
'failed',
|
|
1306
|
+
'stopping',
|
|
1307
|
+
];
|
|
1308
|
+
const CAPABILITIES = new Set([
|
|
1309
|
+
'arming',
|
|
1310
|
+
'audio',
|
|
1311
|
+
'battery',
|
|
1312
|
+
'camera',
|
|
1313
|
+
'contact',
|
|
1314
|
+
'light',
|
|
1315
|
+
'lock',
|
|
1316
|
+
'siren',
|
|
1317
|
+
'smart_light',
|
|
1318
|
+
]);
|
|
1319
|
+
const MEMBERS = new Set([
|
|
1320
|
+
'active',
|
|
1321
|
+
'alarm',
|
|
1322
|
+
'batteryAlert',
|
|
1323
|
+
'brightness',
|
|
1324
|
+
'charging',
|
|
1325
|
+
'color',
|
|
1326
|
+
'enabled',
|
|
1327
|
+
'level',
|
|
1328
|
+
'isOn',
|
|
1329
|
+
'live',
|
|
1330
|
+
'microphone',
|
|
1331
|
+
'mode',
|
|
1332
|
+
'nightVision',
|
|
1333
|
+
'open',
|
|
1334
|
+
'power',
|
|
1335
|
+
'recordFragments',
|
|
1336
|
+
'snapshot',
|
|
1337
|
+
'snapshotLive',
|
|
1338
|
+
'snapshotStored',
|
|
1339
|
+
'speaker',
|
|
1340
|
+
'state',
|
|
1341
|
+
'statusLed',
|
|
1342
|
+
'stop',
|
|
1343
|
+
'talkback',
|
|
1344
|
+
'target',
|
|
1345
|
+
'test',
|
|
1346
|
+
'volume',
|
|
1347
|
+
]);
|
|
1348
|
+
const REASONS = new Set([
|
|
1349
|
+
'adaptation-exited-before-output',
|
|
1350
|
+
'adaptation-exited-while-streaming',
|
|
1351
|
+
'adaptation-failed',
|
|
1352
|
+
'adaptation-spawn-failed',
|
|
1353
|
+
'adapter-missing',
|
|
1354
|
+
'at-capacity',
|
|
1355
|
+
'capability-not-supported',
|
|
1356
|
+
'disabled',
|
|
1357
|
+
'disabled-mid-session',
|
|
1358
|
+
'disabled-no-video',
|
|
1359
|
+
'expired',
|
|
1360
|
+
'hot',
|
|
1361
|
+
'live-at-capacity',
|
|
1362
|
+
'live-decoder-unavailable',
|
|
1363
|
+
'live-failed',
|
|
1364
|
+
'live-no-keyframe',
|
|
1365
|
+
'live-source-failed',
|
|
1366
|
+
'live-unavailable',
|
|
1367
|
+
'live-undecodable-burst',
|
|
1368
|
+
'malformed',
|
|
1369
|
+
'missing',
|
|
1370
|
+
'missing-evidence',
|
|
1371
|
+
'missing-trigger',
|
|
1372
|
+
'no-acquisition',
|
|
1373
|
+
'no-output-within-backstop',
|
|
1374
|
+
'no-primary-purpose-member',
|
|
1375
|
+
'no-retained-image',
|
|
1376
|
+
'not-confirmed',
|
|
1377
|
+
'no-video-within-backstop',
|
|
1378
|
+
'operation-failure',
|
|
1379
|
+
'primary-adapter-unavailable',
|
|
1380
|
+
'recovered',
|
|
1381
|
+
'rtcp-timeout',
|
|
1382
|
+
'sdk-fault',
|
|
1383
|
+
'source-acquisition-timeout',
|
|
1384
|
+
'source-audio-only',
|
|
1385
|
+
'source-error',
|
|
1386
|
+
'source-input-unstable',
|
|
1387
|
+
'source-stopped',
|
|
1388
|
+
'source-unavailable',
|
|
1389
|
+
'stored-download-failed',
|
|
1390
|
+
'stored-failed',
|
|
1391
|
+
'stored-invalid-image',
|
|
1392
|
+
'stored-not-observed',
|
|
1393
|
+
'stored-pending',
|
|
1394
|
+
'stored-unavailable',
|
|
1395
|
+
'timeout',
|
|
1396
|
+
'unsupported',
|
|
1397
|
+
'unsupported-selection',
|
|
1398
|
+
'device-audio-failed',
|
|
1399
|
+
]);
|
|
1400
|
+
/** Every runtime state, plus the withdrawal a recovered condition reports. */
|
|
1401
|
+
const RUNTIME_CONDITION_REASONS = new Set([...RUNTIME_STATES, 'recovered']);
|
|
1402
|
+
const MAX_ACCESSORY_ALIASES = 32;
|
|
1403
|
+
const HOMEKIT_EVENT_ROUTES = {
|
|
1404
|
+
'battery.status': new Set(['battery-alert', 'battery-level']),
|
|
1405
|
+
'contact.sensor': new Set(['contact-state']),
|
|
1406
|
+
'doorbell.press': new Set(['doorbell-press']),
|
|
1407
|
+
'lock.mechanism': new Set(['lock-state']),
|
|
1408
|
+
'motion.sensor': new Set(['motion-detection']),
|
|
1409
|
+
'arming.security-system': new Set(['arming-mode-changed', 'security-system-alarm']),
|
|
1410
|
+
'smart-light.lightbulb': new Set(['smart-light-state']),
|
|
1411
|
+
'camera.streaming': new Set(['camera-enabled-changed']),
|
|
1412
|
+
'camera.controls': new Set(['camera-enabled-changed']),
|
|
1413
|
+
};
|
|
1414
|
+
const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
|
|
1415
|
+
/**
|
|
1416
|
+
* What announced a change, for an adapter that follows more than one announcement for the same state.
|
|
1417
|
+
*
|
|
1418
|
+
* Read by both halves of the write path: the reporter builds the record and the file sink rebuilds it. The
|
|
1419
|
+
* field is omitted where an adapter states no announcement and refused where it states one absent from here.
|
|
1420
|
+
*/
|
|
1421
|
+
const HOMEKIT_ANNOUNCEMENTS = new Set(['write', 'poll']);
|
|
1422
|
+
const HOMEKIT_LIVE_VIDEO_OPERATIONS = new Set(['start', 'reconfigure']);
|
|
1423
|
+
const HOMEKIT_LIVE_VIDEO_PROFILES = new Set(['baseline', 'main', 'high']);
|
|
1424
|
+
const HOMEKIT_LIVE_VIDEO_LEVELS = new Set(['3.1', '3.2', '4.0']);
|
|
1425
|
+
/**
|
|
1426
|
+
* The inclusive bounds a controller-selected live geometry and frame rate are retained within.
|
|
1427
|
+
*
|
|
1428
|
+
* Bounds rather than an enumeration, because a controller may select a geometry the advertised matrix does not
|
|
1429
|
+
* offer and the plugin honours it. The ceiling exceeds what the recorded H.264 level codes, so a selection its
|
|
1430
|
+
* own level cannot carry is retained.
|
|
1431
|
+
*/
|
|
1432
|
+
const MIN_LIVE_VIDEO_DIMENSION = 120;
|
|
1433
|
+
const MAX_LIVE_VIDEO_DIMENSION = 4096;
|
|
1434
|
+
const MIN_LIVE_VIDEO_FRAME_RATE = 1;
|
|
1435
|
+
const MAX_LIVE_VIDEO_FRAME_RATE = 120;
|
|
1436
|
+
/**
|
|
1437
|
+
* The datagram size a controller negotiated, bounded to what the field on the wire can carry.
|
|
1438
|
+
*
|
|
1439
|
+
* HomeKit transports the maximum MTU as an unsigned 16-bit integer, so every value a controller can ask for
|
|
1440
|
+
* is inside these bounds and none is refused for being larger than this host would choose. A packet size the
|
|
1441
|
+
* path cannot carry is the observation, not a reason to drop the record carrying it.
|
|
1442
|
+
*/
|
|
1443
|
+
const MIN_LIVE_VIDEO_MTU = 0;
|
|
1444
|
+
const MAX_LIVE_VIDEO_MTU = 65_535;
|
|
1445
|
+
/** The two address families HomeKit negotiates a destination in. */
|
|
1446
|
+
const HOMEKIT_LIVE_ADDRESS_VERSIONS = new Set(['ipv4', 'ipv6']);
|
|
1447
|
+
const HOMEKIT_LIVE_SESSION_STAGES = new Set([
|
|
1448
|
+
'sdk-source-acquisition',
|
|
1449
|
+
'first-source-keyframe',
|
|
1450
|
+
'first-adapted-output',
|
|
1451
|
+
'controller-rtcp',
|
|
1452
|
+
]);
|
|
1453
|
+
/**
|
|
1454
|
+
* Which adaptation process an FFmpeg record came from.
|
|
1455
|
+
*
|
|
1456
|
+
* A superset of what the media domain can report, because the SDK runs FFmpeg of its own for snapshot
|
|
1457
|
+
* decoding and WebRTC containers and forwards its output here under the same evidence class.
|
|
1458
|
+
*/
|
|
1459
|
+
const ADAPTATION_ROLES = new Set(['live-video', 'live-audio', 'return-audio', 'recording', 'sdk']);
|
|
1460
|
+
/** What that process did. `output` is a process reported for what it wrote rather than for how it ended. */
|
|
1461
|
+
const ADAPTATION_EVENTS = new Set([
|
|
1462
|
+
'started',
|
|
1463
|
+
'spawn-failed',
|
|
1464
|
+
'input-failed',
|
|
1465
|
+
'exited-before-output',
|
|
1466
|
+
'exited-while-streaming',
|
|
1467
|
+
'output',
|
|
1468
|
+
]);
|
|
1469
|
+
/**
|
|
1470
|
+
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1471
|
+
*
|
|
1472
|
+
* The others are a stream working: `started` is a process beginning, and `output` is a process reported for
|
|
1473
|
+
* what it wrote on the way to a stop it was asked to make — the exit code of a killed FFmpeg among it. Neither
|
|
1474
|
+
* is something a reader must act on, and levelling the class rather than the record put four warnings in the
|
|
1475
|
+
* log for every session that succeeded.
|
|
1476
|
+
*/
|
|
1477
|
+
const ADAPTATION_FAILURE_EVENTS = new Set([
|
|
1478
|
+
'spawn-failed',
|
|
1479
|
+
'input-failed',
|
|
1480
|
+
'exited-before-output',
|
|
1481
|
+
'exited-while-streaming',
|
|
1482
|
+
]);
|
|
1483
|
+
/**
|
|
1484
|
+
* The signals a terminated adaptation is reported under.
|
|
1485
|
+
*
|
|
1486
|
+
* A signal this build cannot name is dropped rather than passed through, because the field is written from
|
|
1487
|
+
* an operating-system string and an allowlist is the only thing that keeps it one.
|
|
1488
|
+
*/
|
|
1489
|
+
const ADAPTATION_SIGNALS = new Set([
|
|
1490
|
+
'SIGABRT',
|
|
1491
|
+
'SIGBUS',
|
|
1492
|
+
'SIGFPE',
|
|
1493
|
+
'SIGILL',
|
|
1494
|
+
'SIGINT',
|
|
1495
|
+
'SIGKILL',
|
|
1496
|
+
'SIGPIPE',
|
|
1497
|
+
'SIGSEGV',
|
|
1498
|
+
'SIGTERM',
|
|
1499
|
+
]);
|
|
1500
|
+
/**
|
|
1501
|
+
* How many stderr lines one record keeps.
|
|
1502
|
+
*
|
|
1503
|
+
* A producer bounds its own retention too, and this bound is applied again rather than trusted, because a
|
|
1504
|
+
* record's size is this module's to answer for however many lines it was offered.
|
|
1505
|
+
*/
|
|
1506
|
+
const MAX_ADAPTATION_STDERR_LINES = 8;
|
|
1507
|
+
const MAX_ADAPTATION_STDERR_LINE_LENGTH = 200;
|
|
1508
|
+
/**
|
|
1509
|
+
* The media notices this module retains, read by both halves of the path.
|
|
1510
|
+
*
|
|
1511
|
+
* Operational rather than verbose, so each is `plugin-log` evidence that every profile declares.
|
|
1512
|
+
*/
|
|
1513
|
+
const MEDIA_NOTICES = {
|
|
1514
|
+
'camera-snapshot-cache-invalid': {
|
|
1515
|
+
level: 'warn',
|
|
1516
|
+
messageKey: 'log.snapshotCacheInvalid',
|
|
1517
|
+
},
|
|
1518
|
+
};
|
|
1519
|
+
const RUNTIME_NOTICES = {
|
|
1520
|
+
'status-publication-failed': {
|
|
1521
|
+
level: 'warn',
|
|
1522
|
+
messageKey: 'log.notice.statusPublicationFailed',
|
|
1523
|
+
},
|
|
1524
|
+
'ownership-release-not-finalized': {
|
|
1525
|
+
level: 'error',
|
|
1526
|
+
messageKey: 'log.notice.ownershipReleaseNotFinalized',
|
|
1527
|
+
},
|
|
1528
|
+
'ownership-release-failed': {
|
|
1529
|
+
level: 'error',
|
|
1530
|
+
messageKey: 'log.notice.ownershipReleaseFailed',
|
|
1531
|
+
},
|
|
1532
|
+
'shutdown-failed': {
|
|
1533
|
+
level: 'error',
|
|
1534
|
+
messageKey: 'log.notice.shutdownFailed',
|
|
1535
|
+
},
|
|
1536
|
+
'shutdown-timeout': {
|
|
1537
|
+
level: 'warn',
|
|
1538
|
+
messageKey: 'log.notice.shutdownTimeout',
|
|
1539
|
+
durationMessageKey: 'log.notice.shutdownTimeoutWithDuration',
|
|
1540
|
+
},
|
|
1541
|
+
'ownership-acquisition-failed': {
|
|
1542
|
+
level: 'error',
|
|
1543
|
+
messageKey: 'log.notice.ownershipAcquisitionFailed',
|
|
1544
|
+
},
|
|
1545
|
+
'ownership-release-incomplete': {
|
|
1546
|
+
level: 'warn',
|
|
1547
|
+
messageKey: 'log.notice.ownershipReleaseIncomplete',
|
|
1548
|
+
},
|
|
1549
|
+
'registry-subscriber-failed': {
|
|
1550
|
+
level: 'warn',
|
|
1551
|
+
messageKey: 'log.notice.registrySubscriberFailed',
|
|
1552
|
+
},
|
|
1553
|
+
'event-subscriber-failed': {
|
|
1554
|
+
level: 'warn',
|
|
1555
|
+
messageKey: 'log.notice.eventSubscriberFailed',
|
|
1556
|
+
},
|
|
1557
|
+
'state-subscriber-failed': {
|
|
1558
|
+
level: 'warn',
|
|
1559
|
+
messageKey: 'log.notice.stateSubscriberFailed',
|
|
1560
|
+
},
|
|
1561
|
+
'channel-serve-failed': {
|
|
1562
|
+
level: 'warn',
|
|
1563
|
+
messageKey: 'log.notice.channelServeFailed',
|
|
1564
|
+
},
|
|
1565
|
+
'diagnostics-authorization-armed': {
|
|
1566
|
+
level: 'info',
|
|
1567
|
+
messageKey: 'log.notice.diagnosticsAuthorizationArmed',
|
|
1568
|
+
},
|
|
1569
|
+
'stand-down-not-rearmed': {
|
|
1570
|
+
level: 'warn',
|
|
1571
|
+
messageKey: 'log.notice.standDownNotRearmed',
|
|
1572
|
+
},
|
|
1573
|
+
'stand-down-account-replaced': {
|
|
1574
|
+
level: 'warn',
|
|
1575
|
+
messageKey: 'log.notice.standDownAccountReplaced',
|
|
1576
|
+
},
|
|
1577
|
+
};
|
|
1578
|
+
const RUNTIME_CONDITIONS = {
|
|
1579
|
+
degraded: {
|
|
1580
|
+
code: 'runtime-transport-degraded',
|
|
1581
|
+
summaryKey: 'log.runtime.transportDegraded',
|
|
1582
|
+
actionKey: 'log.action.checkNetwork',
|
|
1583
|
+
level: 'warn',
|
|
1584
|
+
},
|
|
1585
|
+
'authentication-required': {
|
|
1586
|
+
code: 'runtime-authentication-required',
|
|
1587
|
+
summaryKey: 'log.runtime.authenticationRequired',
|
|
1588
|
+
actionKey: 'log.action.reauthenticate',
|
|
1589
|
+
level: 'warn',
|
|
1590
|
+
},
|
|
1591
|
+
'owner-conflict': {
|
|
1592
|
+
code: 'runtime-owner-conflict',
|
|
1593
|
+
summaryKey: 'log.runtime.ownerConflict',
|
|
1594
|
+
actionKey: 'log.action.stopOtherOwner',
|
|
1595
|
+
level: 'error',
|
|
1596
|
+
},
|
|
1597
|
+
failed: {
|
|
1598
|
+
code: 'runtime-failed',
|
|
1599
|
+
summaryKey: 'log.runtime.failed',
|
|
1600
|
+
actionKey: 'log.action.reviewRuntime',
|
|
1601
|
+
level: 'error',
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
const HOMEKIT_CONDITIONS = {
|
|
1605
|
+
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.openDashboard'],
|
|
1606
|
+
'battery-capability-unavailable': ['log.homekit.batteryCapabilityUnavailable', 'log.action.waitBattery'],
|
|
1607
|
+
'invalid-battery-observation': ['log.homekit.invalidBatteryObservation', 'log.action.waitBatteryObservation'],
|
|
1608
|
+
'battery-temperature-alert': ['log.homekit.batteryTemperatureAlert', 'log.action.allowBatteryCooling'],
|
|
1609
|
+
'contact-capability-unavailable': ['log.homekit.contactCapabilityUnavailable', 'log.action.waitContact'],
|
|
1610
|
+
'invalid-contact-observation': ['log.homekit.invalidContactObservation', 'log.action.waitContactObservation'],
|
|
1611
|
+
'siren-capability-unavailable': ['log.homekit.sirenCapabilityUnavailable', 'log.action.waitSiren'],
|
|
1612
|
+
'invalid-siren-active-observation': ['log.homekit.invalidSirenObservation', 'log.action.waitSirenObservation'],
|
|
1613
|
+
'smart-light-capability-unavailable': ['log.homekit.lightCapabilityUnavailable', 'log.action.waitLight'],
|
|
1614
|
+
'invalid-smart-light-observation': ['log.homekit.invalidLightObservation', 'log.action.waitLightObservation'],
|
|
1615
|
+
'smart-light-operation-failed': ['log.homekit.lightOperationFailed', 'log.action.retryLight'],
|
|
1616
|
+
'smart-light-reconciliation-expired': ['log.homekit.lightReconciliationExpired', 'log.action.checkPhysicalLight'],
|
|
1617
|
+
'arming-capability-unavailable': ['log.homekit.armingCapabilityUnavailable', 'log.action.waitArming'],
|
|
1618
|
+
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.selectSupportedArmingMode'],
|
|
1619
|
+
'arming-operation-failed': ['log.homekit.armingOperationFailed', 'log.action.retryArming'],
|
|
1620
|
+
'arming-reconciliation-expired': ['log.homekit.armingReconciliationExpired', 'log.action.checkPhysicalArmingMode'],
|
|
1621
|
+
'lock-capability-unavailable': ['log.homekit.lockCapabilityUnavailable', 'log.action.waitLock'],
|
|
1622
|
+
'lock-operation-failed': ['log.homekit.lockOperationFailed', 'log.action.retryLock'],
|
|
1623
|
+
'lock-reconciliation-expired': ['log.homekit.lockReconciliationExpired', 'log.action.checkPhysicalLock'],
|
|
1624
|
+
'unusable-lock-announcement': ['log.homekit.unusableLockAnnouncement', 'log.action.checkPhysicalLock'],
|
|
1625
|
+
'camera-live-session-failed': ['log.homekit.cameraLiveSessionFailed', 'log.action.retryLiveView'],
|
|
1626
|
+
'camera-live-session-refused': ['log.homekit.cameraLiveSessionRefused', 'log.action.enableCamera'],
|
|
1627
|
+
'camera-media-at-capacity': ['log.homekit.cameraMediaAtCapacity', 'log.action.reduceConcurrentMedia'],
|
|
1628
|
+
'camera-recording-unavailable': ['log.homekit.cameraRecordingUnavailable', 'log.action.checkCameraRecording'],
|
|
1629
|
+
'camera-recording-failed': ['log.homekit.cameraRecordingFailed', 'log.action.retryRecording'],
|
|
1630
|
+
'camera-recording-refused': ['log.homekit.cameraRecordingRefused', 'log.action.enableCamera'],
|
|
1631
|
+
'camera-controls-capability-unavailable': [
|
|
1632
|
+
'log.homekit.cameraControlsCapabilityUnavailable',
|
|
1633
|
+
'log.action.waitCameraControl',
|
|
1634
|
+
],
|
|
1635
|
+
'camera-control-operation-failed': ['log.homekit.cameraControlOperationFailed', 'log.action.retryCameraControl'],
|
|
1636
|
+
'invalid-camera-control-observation': [
|
|
1637
|
+
'log.homekit.invalidCameraControlObservation',
|
|
1638
|
+
'log.action.checkCameraControl',
|
|
1639
|
+
],
|
|
1640
|
+
'camera-snapshot-unavailable': ['log.homekit.cameraSnapshotUnavailable', 'log.action.checkCameraSnapshot'],
|
|
1641
|
+
'camera-snapshot-capability-unavailable': [
|
|
1642
|
+
'log.homekit.cameraSnapshotCapabilityUnavailable',
|
|
1643
|
+
'log.action.waitCameraSnapshot',
|
|
1644
|
+
],
|
|
1645
|
+
'camera-streaming-capability-unavailable': [
|
|
1646
|
+
'log.homekit.cameraStreamingCapabilityUnavailable',
|
|
1647
|
+
'log.action.waitCameraLive',
|
|
1648
|
+
],
|
|
1649
|
+
'camera-talkback-failed': ['log.homekit.cameraTalkbackFailed', 'log.action.retryTalkback'],
|
|
1650
|
+
'camera-talkback-capability-unavailable': ['log.homekit.cameraTalkbackUnavailable', 'log.action.waitTalkback'],
|
|
1651
|
+
};
|
|
1652
|
+
function sanitizeStructuredEvent(message) {
|
|
1653
|
+
let value;
|
|
1654
|
+
try {
|
|
1655
|
+
const parsed = JSON.parse(message);
|
|
1656
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1657
|
+
return undefined;
|
|
1658
|
+
value = parsed;
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
return undefined;
|
|
1662
|
+
}
|
|
1663
|
+
const level = allowlistedLabel(value.level, ['debug', 'info', 'warn', 'error']);
|
|
1664
|
+
if (!level || typeof value.scope !== 'string')
|
|
1665
|
+
return undefined;
|
|
1666
|
+
if (value.scope === 'sdk') {
|
|
1667
|
+
if (typeof value.subsystem !== 'string' ||
|
|
1668
|
+
!SDK_SUBSYSTEMS.has(value.subsystem) ||
|
|
1669
|
+
typeof value.event !== 'string' ||
|
|
1670
|
+
!SDK_EVENT_KEYS.has(value.event)) {
|
|
1671
|
+
return undefined;
|
|
1672
|
+
}
|
|
1673
|
+
if (value.event === 'live-start-trace') {
|
|
1674
|
+
if (level !== 'debug')
|
|
1675
|
+
return undefined;
|
|
1676
|
+
const trace = sanitizeSdkLiveStartTrace(value);
|
|
1677
|
+
return trace ? { scope: 'sdk', level, subsystem: 'p2p', event: 'live-start-trace', ...trace } : undefined;
|
|
1678
|
+
}
|
|
1679
|
+
const details = [];
|
|
1680
|
+
for (const detail of Array.isArray(value.details) ? value.details.slice(0, MAX_SDK_DETAILS) : []) {
|
|
1681
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail))
|
|
1682
|
+
continue;
|
|
1683
|
+
const candidate = detail;
|
|
1684
|
+
const errorType = allowlistedLabel(candidate.errorType, [
|
|
1685
|
+
'Error',
|
|
1686
|
+
'RangeError',
|
|
1687
|
+
'SessionExpiredError',
|
|
1688
|
+
'TypeError',
|
|
1689
|
+
]);
|
|
1690
|
+
if (errorType) {
|
|
1691
|
+
details.push({ errorType });
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
const type = allowlistedLabel(candidate.type, ['boolean', 'number', 'object', 'string', 'undefined']);
|
|
1695
|
+
if (!type)
|
|
1696
|
+
continue;
|
|
1697
|
+
const length = nonNegativeInteger(candidate.length);
|
|
1698
|
+
details.push({ type, ...(length === undefined ? {} : { length }) });
|
|
1699
|
+
}
|
|
1700
|
+
return {
|
|
1701
|
+
scope: 'sdk',
|
|
1702
|
+
level,
|
|
1703
|
+
subsystem: value.subsystem,
|
|
1704
|
+
event: value.event,
|
|
1705
|
+
...(details.length ? { details } : {}),
|
|
1706
|
+
...(value.detailsTruncated === true ? { detailsTruncated: true } : {}),
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (value.scope === 'runtime-notice') {
|
|
1710
|
+
if (typeof value.code !== 'string' || !Object.hasOwn(RUNTIME_NOTICES, value.code))
|
|
1711
|
+
return undefined;
|
|
1712
|
+
const notice = RUNTIME_NOTICES[value.code];
|
|
1713
|
+
const durationMs = nonNegativeInteger(value.durationMs);
|
|
1714
|
+
const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
|
|
1715
|
+
return {
|
|
1716
|
+
scope: 'runtime-notice',
|
|
1717
|
+
level: notice.level,
|
|
1718
|
+
code: value.code,
|
|
1719
|
+
messageKey,
|
|
1720
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
if (value.scope === 'configuration-notice') {
|
|
1724
|
+
if (value.code !== 'discarded-v4-settings-unacknowledged')
|
|
1725
|
+
return undefined;
|
|
1726
|
+
return {
|
|
1727
|
+
scope: 'configuration-notice',
|
|
1728
|
+
level: 'warn',
|
|
1729
|
+
code: value.code,
|
|
1730
|
+
messageKey: 'log.discardedSettings',
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
if (value.scope === 'media-notice') {
|
|
1734
|
+
if (typeof value.code !== 'string' || !Object.hasOwn(MEDIA_NOTICES, value.code))
|
|
1735
|
+
return undefined;
|
|
1736
|
+
const notice = MEDIA_NOTICES[value.code];
|
|
1737
|
+
return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
|
|
1738
|
+
}
|
|
1739
|
+
if (value.scope === 'homekit') {
|
|
1740
|
+
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
1741
|
+
const selection = sanitizeLiveVideoSelection(value);
|
|
1742
|
+
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
1743
|
+
}
|
|
1744
|
+
if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
|
|
1745
|
+
const lifecycle = sanitizeLiveSessionTrace(value);
|
|
1746
|
+
return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
|
|
1747
|
+
}
|
|
1748
|
+
if (typeof value.adapter !== 'string' ||
|
|
1749
|
+
typeof value.event !== 'string' ||
|
|
1750
|
+
!HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
|
|
1751
|
+
typeof value.observation !== 'string' ||
|
|
1752
|
+
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
1753
|
+
return undefined;
|
|
1754
|
+
}
|
|
1755
|
+
return {
|
|
1756
|
+
scope: 'homekit',
|
|
1757
|
+
level: 'debug',
|
|
1758
|
+
adapter: value.adapter,
|
|
1759
|
+
event: value.event,
|
|
1760
|
+
observation: value.observation,
|
|
1761
|
+
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
1762
|
+
? { announcedBy: value.announcedBy }
|
|
1763
|
+
: {}),
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
if (value.scope === 'runtime') {
|
|
1767
|
+
const event = allowlistedLabel(value.event, ['ready', 'stopped']);
|
|
1768
|
+
if (!event)
|
|
1769
|
+
return undefined;
|
|
1770
|
+
return { scope: 'runtime', level: 'info', event, messageKey: 'log.runtime.state' };
|
|
1771
|
+
}
|
|
1772
|
+
if (value.scope === 'ffmpeg') {
|
|
1773
|
+
return sanitizeAdaptationNotice(value, level);
|
|
1774
|
+
}
|
|
1775
|
+
if (value.scope === 'diagnostic-condition') {
|
|
1776
|
+
const homeKitDefinition = typeof value.code === 'string' && Object.hasOwn(HOMEKIT_CONDITIONS, value.code)
|
|
1777
|
+
? HOMEKIT_CONDITIONS[value.code]
|
|
1778
|
+
: undefined;
|
|
1779
|
+
const runtimeDefinition = Object.values(RUNTIME_CONDITIONS).find(({ code }) => code === value.code);
|
|
1780
|
+
if (typeof value.code !== 'string' || (!homeKitDefinition && !runtimeDefinition))
|
|
1781
|
+
return undefined;
|
|
1782
|
+
if (typeof value.active !== 'boolean' || typeof value.reason !== 'string')
|
|
1783
|
+
return undefined;
|
|
1784
|
+
if (runtimeDefinition) {
|
|
1785
|
+
if (!RUNTIME_CONDITION_REASONS.has(value.reason))
|
|
1786
|
+
return undefined;
|
|
1787
|
+
if (value.capability !== undefined ||
|
|
1788
|
+
value.member !== undefined ||
|
|
1789
|
+
value.affectedAccessoryCount !== undefined ||
|
|
1790
|
+
value.accessoryAliases !== undefined ||
|
|
1791
|
+
value.aliasesTruncated !== undefined) {
|
|
1792
|
+
return undefined;
|
|
1793
|
+
}
|
|
1794
|
+
return {
|
|
1795
|
+
scope: 'diagnostic-condition',
|
|
1796
|
+
level: value.active ? runtimeDefinition.level : 'info',
|
|
1797
|
+
code: value.code,
|
|
1798
|
+
active: value.active,
|
|
1799
|
+
reason: value.reason,
|
|
1800
|
+
summaryKey: runtimeDefinition.summaryKey,
|
|
1801
|
+
actionKey: runtimeDefinition.actionKey,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
if (!REASONS.has(value.reason))
|
|
1805
|
+
return undefined;
|
|
1806
|
+
const affectedAccessoryCount = nonNegativeInteger(value.affectedAccessoryCount);
|
|
1807
|
+
const accessoryAliases = Array.isArray(value.accessoryAliases)
|
|
1808
|
+
? value.accessoryAliases
|
|
1809
|
+
.filter((alias) => typeof alias === 'string' && /^accessory-[0-9a-f-]{36}$/.test(alias))
|
|
1810
|
+
.slice(0, MAX_ACCESSORY_ALIASES)
|
|
1811
|
+
: [];
|
|
1812
|
+
return {
|
|
1813
|
+
scope: 'diagnostic-condition',
|
|
1814
|
+
level: value.active ? 'warn' : 'info',
|
|
1815
|
+
code: value.code,
|
|
1816
|
+
active: value.active,
|
|
1817
|
+
reason: value.reason,
|
|
1818
|
+
summaryKey: homeKitDefinition[0],
|
|
1819
|
+
actionKey: homeKitDefinition[1],
|
|
1820
|
+
...(typeof value.capability === 'string' && CAPABILITIES.has(value.capability)
|
|
1821
|
+
? { capability: value.capability }
|
|
1822
|
+
: {}),
|
|
1823
|
+
...(typeof value.member === 'string' && MEMBERS.has(value.member) ? { member: value.member } : {}),
|
|
1824
|
+
...(affectedAccessoryCount === undefined ? {} : { affectedAccessoryCount }),
|
|
1825
|
+
...(accessoryAliases.length ? { accessoryAliases } : {}),
|
|
1826
|
+
...(value.aliasesTruncated === true ? { aliasesTruncated: true } : {}),
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
return undefined;
|
|
1830
|
+
}
|
|
1831
|
+
/** Emits one fixed-shape operational notice selected by its allowlisted runtime code. */
|
|
1832
|
+
export function reportRuntimeNotice(target, code, fields = {}) {
|
|
1833
|
+
const notice = RUNTIME_NOTICES[code];
|
|
1834
|
+
const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
|
|
1835
|
+
const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
|
|
1836
|
+
target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0 })}`);
|
|
1837
|
+
target.debug?.(JSON.stringify({
|
|
1838
|
+
scope: 'runtime-notice',
|
|
1839
|
+
level: notice.level,
|
|
1840
|
+
code,
|
|
1841
|
+
messageKey,
|
|
1842
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
1843
|
+
}));
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Picks up an authorization the persisted session file confirms, reporting whether it did.
|
|
1847
|
+
*
|
|
1848
|
+
* The file is the authority: the supplied identifier only names which window the caller believes was opened,
|
|
1849
|
+
* and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
|
|
1850
|
+
* one operational record, which is the evidence that this process began retaining the window's scopes, taken
|
|
1851
|
+
* at the moment it did rather than whenever a later record arrives.
|
|
1852
|
+
*/
|
|
1853
|
+
export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
|
|
1854
|
+
if (!isSupportCaseId(supportCaseId) ||
|
|
1855
|
+
activeDiagnosticsSession(storageRoot, now())?.supportCaseId !== supportCaseId) {
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
reportRuntimeNotice(target, 'diagnostics-authorization-armed');
|
|
1859
|
+
return true;
|
|
1860
|
+
}
|
|
1861
|
+
/** Emits the startup notice for discarded settings awaiting acknowledgement. */
|
|
1862
|
+
export function reportDiscardedV4Settings(target) {
|
|
1863
|
+
const code = 'discarded-v4-settings-unacknowledged';
|
|
1864
|
+
const messageKey = 'log.discardedSettings';
|
|
1865
|
+
target.warn(`[${code}] ${localize(target, messageKey)}`);
|
|
1866
|
+
target.debug?.(JSON.stringify({
|
|
1867
|
+
scope: 'configuration-notice',
|
|
1868
|
+
level: 'warn',
|
|
1869
|
+
code,
|
|
1870
|
+
messageKey,
|
|
1871
|
+
}));
|
|
1872
|
+
}
|
|
1873
|
+
/** Emits one identity-free warning after an invalid retained camera image is discarded. */
|
|
1874
|
+
export function reportInvalidSnapshotCache(target) {
|
|
1875
|
+
const code = 'camera-snapshot-cache-invalid';
|
|
1876
|
+
const { level, messageKey } = MEDIA_NOTICES[code];
|
|
1877
|
+
target[level](`[${code}] ${localize(target, messageKey)}`);
|
|
1878
|
+
target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
|
|
1879
|
+
}
|
|
1880
|
+
/** Emits one allowlisted HomeKit event trace only when host debug output is available. */
|
|
1881
|
+
export function reportHomeKitEvent(target, trace) {
|
|
1882
|
+
if (trace.event === 'live-video-selected') {
|
|
1883
|
+
const selection = sanitizeLiveVideoSelection(trace);
|
|
1884
|
+
if (target.debug && selection) {
|
|
1885
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
|
|
1886
|
+
}
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
|
|
1890
|
+
const lifecycle = sanitizeLiveSessionTrace(trace);
|
|
1891
|
+
if (target.debug && lifecycle) {
|
|
1892
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
|
|
1893
|
+
}
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
if (!target.debug ||
|
|
1897
|
+
!('observation' in trace) ||
|
|
1898
|
+
!HOMEKIT_EVENT_ROUTES[trace.adapter]?.has(trace.event) ||
|
|
1899
|
+
typeof trace.observation !== 'string' ||
|
|
1900
|
+
!HOMEKIT_OBSERVATIONS.has(trace.observation)) {
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
target.debug(JSON.stringify({
|
|
1904
|
+
scope: 'homekit',
|
|
1905
|
+
level: 'debug',
|
|
1906
|
+
adapter: trace.adapter,
|
|
1907
|
+
event: trace.event,
|
|
1908
|
+
observation: trace.observation,
|
|
1909
|
+
...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
|
|
1910
|
+
? { announcedBy: trace.announcedBy }
|
|
1911
|
+
: {}),
|
|
1912
|
+
}));
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Records one FFmpeg adaptation fact as the `ffmpeg-log` evidence the media support profiles declare.
|
|
1916
|
+
*
|
|
1917
|
+
* Nothing is written to the human console: an adaptation failure already reaches the user as the bounded
|
|
1918
|
+
* HomeKit condition its camera reports, and repeating each process's own exit there would say the same thing
|
|
1919
|
+
* a second time in a vocabulary only a maintainer can read.
|
|
1920
|
+
*/
|
|
1921
|
+
export function reportAdaptationNotice(target, trace) {
|
|
1922
|
+
if (!target.debug) {
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const level = ADAPTATION_FAILURE_EVENTS.has(trace.event) ? 'warn' : 'debug';
|
|
1926
|
+
const notice = sanitizeAdaptationNotice({ ...trace, level }, level);
|
|
1927
|
+
if (notice) {
|
|
1928
|
+
target.debug(JSON.stringify(notice));
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it.
|
|
1933
|
+
*
|
|
1934
|
+
* The line is the only place an encoder-level cause is stated outright, and it is also the only place this
|
|
1935
|
+
* plugin's own argument list can be echoed back: the output URL carries base64 SRTP key material, the
|
|
1936
|
+
* controller address sits beside it, and an SDK snapshot filename carries a device serial. Each of those is
|
|
1937
|
+
* replaced by what it is rather than searched for afterwards, because a redaction that depends on
|
|
1938
|
+
* recognising a secret fails on the first message shape nobody predicted.
|
|
1939
|
+
*
|
|
1940
|
+
* The key material is replaced before the path rules run, and not after. Base64 includes `/`, so a path rule
|
|
1941
|
+
* applied first splits a key into sub-runs too short for any length threshold to catch — measured on random
|
|
1942
|
+
* 30-byte keys, roughly one line in twenty then kept an eight-character fragment verbatim. The cost of this
|
|
1943
|
+
* order is that a long path made only of letters and separators is labelled as redacted rather than as a
|
|
1944
|
+
* path, which loses a label and never a secret.
|
|
1945
|
+
*/
|
|
1946
|
+
function redactAdaptationStderr(line) {
|
|
1947
|
+
const printable = boundedText(line, MAX_ADAPTATION_STDERR_LINE_LENGTH);
|
|
1948
|
+
if (printable === undefined || printable.startsWith('progress=')) {
|
|
1949
|
+
return undefined;
|
|
1950
|
+
}
|
|
1951
|
+
return redactSensitiveText(printable);
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Replaces one run of colon-separated hexadecimal groups with `<address>`, and returns an FFmpeg clock or
|
|
1955
|
+
* timecode unchanged.
|
|
1956
|
+
*
|
|
1957
|
+
* A run carrying a `::`, or three or more groups, is an address, unless it is exactly three or four all-decimal
|
|
1958
|
+
* groups: that is the shape FFmpeg states an elapsed time and an SMPTE timecode in, and no uncompressed address
|
|
1959
|
+
* form is that short, including the six groups that precede an embedded IPv4 quad. Two groups are neither, and
|
|
1960
|
+
* are kept, which leaves a stream index intact.
|
|
1961
|
+
*/
|
|
1962
|
+
function redactColonSeparatedRun(run) {
|
|
1963
|
+
const groups = run.split(':');
|
|
1964
|
+
if ((groups.length === 3 || groups.length === 4) && groups.every((group) => /^\d{1,4}$/.test(group))) {
|
|
1965
|
+
return run;
|
|
1966
|
+
}
|
|
1967
|
+
return run.includes('::') || groups.length > 2 ? '<address>' : run;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Removes from one line everything a support case may not keep, leaving the sentence and its numbers.
|
|
1971
|
+
*
|
|
1972
|
+
* One rule set for every line this plugin retains, whether it came from an adaptation's stderr or from the
|
|
1973
|
+
* SDK's own diagnostics, because what counts as a secret does not depend on which process said it — and two
|
|
1974
|
+
* lists would drift until one of them missed a shape.
|
|
1975
|
+
*
|
|
1976
|
+
* Each kind is replaced by what it is rather than searched for afterwards, since a redaction that depends on
|
|
1977
|
+
* recognising a secret fails on the first message shape nobody predicted.
|
|
1978
|
+
*
|
|
1979
|
+
* Key material is replaced BEFORE the path rules, not after. Base64 includes `/`, so a path rule applied
|
|
1980
|
+
* first splits a key into sub-runs too short for any length threshold to catch — measured on random 30-byte
|
|
1981
|
+
* keys, roughly one line in twenty then kept an eight-character fragment verbatim. The cost of this order is
|
|
1982
|
+
* that a long path made only of letters and separators is labelled as redacted rather than as a path, which
|
|
1983
|
+
* loses a label and never a secret.
|
|
1984
|
+
*
|
|
1985
|
+
* The P2P device id is grouped with key material rather than with identifiers: it is an input to the station's
|
|
1986
|
+
* encryption, so it is a credential.
|
|
1987
|
+
*/
|
|
1988
|
+
function redactSensitiveText(line) {
|
|
1989
|
+
const redacted = line
|
|
1990
|
+
.replace(/[a-z][a-z0-9+.-]*:\/\/\S*/gi, '<url>')
|
|
1991
|
+
.replace(/\[[0-9a-f:]{2,}\]/gi, '<address>')
|
|
1992
|
+
.replace(/\b[A-Z0-9]{7}-[0-9]{6}-[A-Z0-9]{5}\b/g, '<redacted>')
|
|
1993
|
+
.replace(/[A-Za-z0-9+/]{20,}={0,2}/g, '<redacted>')
|
|
1994
|
+
.replace(/[A-Za-z]:\\[^\s]*/g, '<path>')
|
|
1995
|
+
.replace(/(?:\/[\w.@-]+){2,}\/?/g, '<path>')
|
|
1996
|
+
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<address>')
|
|
1997
|
+
.replace(/\b[0-9a-f]{1,4}(?:(?::{1,2}[0-9a-f]{1,4})+(?:::)?|::)|(?<![\w:])::[0-9a-f]{1,4}(?::[0-9a-f]{1,4})*/gi, redactColonSeparatedRun)
|
|
1998
|
+
.replace(/\bT\d[0-9A-Z]{9,}\b/g, '<serial>')
|
|
1999
|
+
.trim();
|
|
2000
|
+
return redacted === '' ? undefined : redacted;
|
|
2001
|
+
}
|
|
2002
|
+
/** Narrows one offered FFmpeg record against the adaptation allowlists, dropping it whole where it fails. */
|
|
2003
|
+
function sanitizeAdaptationNotice(value, level) {
|
|
2004
|
+
if (typeof value.role !== 'string' || !ADAPTATION_ROLES.has(value.role)) {
|
|
2005
|
+
return undefined;
|
|
2006
|
+
}
|
|
2007
|
+
if (typeof value.event !== 'string' || !ADAPTATION_EVENTS.has(value.event)) {
|
|
2008
|
+
return undefined;
|
|
2009
|
+
}
|
|
2010
|
+
const code = boundedInteger(value.code, 255);
|
|
2011
|
+
const signal = typeof value.signal === 'string' && ADAPTATION_SIGNALS.has(value.signal) ? value.signal : undefined;
|
|
2012
|
+
const stderr = (Array.isArray(value.stderr) ? value.stderr : [])
|
|
2013
|
+
.filter((line) => typeof line === 'string')
|
|
2014
|
+
.map(redactAdaptationStderr)
|
|
2015
|
+
.filter((line) => line !== undefined)
|
|
2016
|
+
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2017
|
+
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2018
|
+
return {
|
|
2019
|
+
scope: 'ffmpeg',
|
|
2020
|
+
level,
|
|
2021
|
+
role: value.role,
|
|
2022
|
+
event: value.event,
|
|
2023
|
+
...(code === undefined ? {} : { code }),
|
|
2024
|
+
...(signal === undefined ? {} : { signal }),
|
|
2025
|
+
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
2026
|
+
...(stderr.length ? { stderr } : {}),
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
function sanitizeLiveSessionTrace(value) {
|
|
2030
|
+
if (value.adapter !== 'camera.streaming') {
|
|
2031
|
+
return undefined;
|
|
2032
|
+
}
|
|
2033
|
+
if (value.event === 'live-session-released' || value.event === 'live-session-streaming') {
|
|
2034
|
+
return { adapter: value.adapter, event: value.event };
|
|
2035
|
+
}
|
|
2036
|
+
if (value.event === 'live-request-refused') {
|
|
2037
|
+
return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
|
|
2038
|
+
? { adapter: value.adapter, event: value.event, reason: value.reason }
|
|
2039
|
+
: undefined;
|
|
2040
|
+
}
|
|
2041
|
+
if (value.event === 'live-request-unaccounted') {
|
|
2042
|
+
return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
|
|
2043
|
+
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
|
|
2044
|
+
: undefined;
|
|
2045
|
+
}
|
|
2046
|
+
if (value.event !== 'live-session-failed' ||
|
|
2047
|
+
value.outcome !== 'failed' ||
|
|
2048
|
+
typeof value.reason !== 'string' ||
|
|
2049
|
+
!REASONS.has(value.reason) ||
|
|
2050
|
+
typeof value.stage !== 'string' ||
|
|
2051
|
+
!HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
|
|
2052
|
+
return undefined;
|
|
2053
|
+
}
|
|
2054
|
+
return {
|
|
2055
|
+
adapter: value.adapter,
|
|
2056
|
+
event: value.event,
|
|
2057
|
+
outcome: value.outcome,
|
|
2058
|
+
reason: value.reason,
|
|
2059
|
+
stage: value.stage,
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
function sanitizeLiveVideoSelection(value) {
|
|
2063
|
+
const operation = typeof value.operation === 'string' ? value.operation : undefined;
|
|
2064
|
+
const profile = typeof value.profile === 'string' ? value.profile : undefined;
|
|
2065
|
+
const codecLevel = typeof value.levelName === 'string' ? value.levelName : typeof value.level === 'string' ? value.level : undefined;
|
|
2066
|
+
const width = boundedInteger(value.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2067
|
+
const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2068
|
+
const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
|
|
2069
|
+
const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
|
|
2070
|
+
const addressVersion = typeof value.addressVersion === 'string' ? value.addressVersion : undefined;
|
|
2071
|
+
if (!operation ||
|
|
2072
|
+
!HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
|
|
2073
|
+
!profile ||
|
|
2074
|
+
!HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) ||
|
|
2075
|
+
!codecLevel ||
|
|
2076
|
+
!HOMEKIT_LIVE_VIDEO_LEVELS.has(codecLevel) ||
|
|
2077
|
+
width === undefined ||
|
|
2078
|
+
height === undefined ||
|
|
2079
|
+
fps === undefined ||
|
|
2080
|
+
mtu === undefined ||
|
|
2081
|
+
!addressVersion ||
|
|
2082
|
+
!HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
|
|
2083
|
+
return undefined;
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
adapter: 'camera.streaming',
|
|
2087
|
+
event: 'live-video-selected',
|
|
2088
|
+
operation,
|
|
2089
|
+
profile,
|
|
2090
|
+
levelName: codecLevel,
|
|
2091
|
+
width,
|
|
2092
|
+
height,
|
|
2093
|
+
fps,
|
|
2094
|
+
mtu,
|
|
2095
|
+
addressVersion,
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
/** Emits bounded normal-output condition transitions without stable device or account identity. */
|
|
2099
|
+
export class DiagnosticConditions {
|
|
2100
|
+
log;
|
|
2101
|
+
nameFor;
|
|
2102
|
+
active = new Map();
|
|
2103
|
+
aliases = new Map();
|
|
2104
|
+
runtimeState;
|
|
2105
|
+
/**
|
|
2106
|
+
* @param nameFor Resolves a device serial to the label the owner sees, for the CONSOLE line only.
|
|
2107
|
+
* The console line goes to the owner's own log, where Homebridge prints accessory names on every other
|
|
2108
|
+
* line, and a count is not something an owner can act on. The retained record keeps carrying no identity.
|
|
2109
|
+
* A device with no accessory has no display name, so a caller resolving one for a condition about an
|
|
2110
|
+
* unrepresented device supplies its snapshot label instead.
|
|
2111
|
+
*/
|
|
2112
|
+
constructor(log, nameFor) {
|
|
2113
|
+
this.log = log;
|
|
2114
|
+
this.nameFor = nameFor;
|
|
2115
|
+
}
|
|
2116
|
+
reportRuntimeState(state) {
|
|
2117
|
+
if (state === this.runtimeState) {
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
this.runtimeState = state;
|
|
2121
|
+
const current = RUNTIME_CONDITIONS[state];
|
|
2122
|
+
for (const condition of Object.values(RUNTIME_CONDITIONS)) {
|
|
2123
|
+
if (condition !== current) {
|
|
2124
|
+
this.write(condition.code, false, state === 'ready' ? 'recovered' : state, condition.summaryKey, condition.actionKey, 'info');
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
if (current) {
|
|
2128
|
+
this.write(current.code, true, state, current.summaryKey, current.actionKey, current.level);
|
|
2129
|
+
}
|
|
2130
|
+
else if (state === 'ready' || state === 'stopped') {
|
|
2131
|
+
const messageKey = 'log.runtime.state';
|
|
2132
|
+
this.log.info(`[runtime-${state}] ${localize(this.log, messageKey, { state })}`);
|
|
2133
|
+
this.log.debug?.(JSON.stringify({ scope: 'runtime', level: 'info', event: state, messageKey }));
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Records one HomeKit condition against the accessories it affects.
|
|
2138
|
+
*
|
|
2139
|
+
* The retained record carries aliases and no identity. The owner's own console line carries each name paired
|
|
2140
|
+
* with its alias, which is what makes an alias in a support case resolvable by the owner and by nobody else.
|
|
2141
|
+
*/
|
|
2142
|
+
reportHomeKit(condition, affectedDeviceIds) {
|
|
2143
|
+
const definition = Object.hasOwn(HOMEKIT_CONDITIONS, condition.code)
|
|
2144
|
+
? HOMEKIT_CONDITIONS[condition.code]
|
|
2145
|
+
: undefined;
|
|
2146
|
+
if (definition === undefined ||
|
|
2147
|
+
!REASONS.has(condition.reason) ||
|
|
2148
|
+
(condition.capability !== undefined && !CAPABILITIES.has(condition.capability)) ||
|
|
2149
|
+
(condition.member !== undefined && !MEMBERS.has(condition.member))) {
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
|
|
2153
|
+
const accessoryAliases = uniqueDeviceIds
|
|
2154
|
+
.map((identity) => this.accessoryAlias(identity))
|
|
2155
|
+
.filter((alias) => alias !== undefined)
|
|
2156
|
+
.sort();
|
|
2157
|
+
this.write(condition.code, condition.active, condition.reason, definition[0], definition[1], condition.active ? 'warn' : 'info', {
|
|
2158
|
+
...(condition.capability === undefined ? {} : { capability: condition.capability }),
|
|
2159
|
+
...(condition.member === undefined ? {} : { member: condition.member }),
|
|
2160
|
+
affectedAccessoryCount: uniqueDeviceIds.length,
|
|
2161
|
+
...(accessoryAliases.length === 0 ? {} : { accessoryAliases }),
|
|
2162
|
+
...(accessoryAliases.length === uniqueDeviceIds.length ? {} : { aliasesTruncated: true }),
|
|
2163
|
+
}, `${condition.code}:${condition.capability ?? ''}:${condition.member ?? ''}`, uniqueDeviceIds
|
|
2164
|
+
.map((serial) => {
|
|
2165
|
+
const name = this.nameFor?.(serial);
|
|
2166
|
+
const alias = this.aliases.get(serial);
|
|
2167
|
+
return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
|
|
2168
|
+
})
|
|
2169
|
+
.filter((named) => named !== undefined));
|
|
2170
|
+
}
|
|
2171
|
+
accessoryAlias(identity) {
|
|
2172
|
+
let alias = this.aliases.get(identity);
|
|
2173
|
+
if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
|
|
2174
|
+
alias = `accessory-${randomUUID()}`;
|
|
2175
|
+
this.aliases.set(identity, alias);
|
|
2176
|
+
}
|
|
2177
|
+
return alias;
|
|
2178
|
+
}
|
|
2179
|
+
write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
|
|
2180
|
+
/** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
|
|
2181
|
+
names) {
|
|
2182
|
+
const fingerprint = JSON.stringify({ active, reason, ...fields });
|
|
2183
|
+
if (active) {
|
|
2184
|
+
if (this.active.get(conditionKey) === fingerprint) {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
this.active.set(conditionKey, fingerprint);
|
|
2188
|
+
}
|
|
2189
|
+
else if (!this.active.delete(conditionKey)) {
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const affectedAccessoryCount = typeof fields.affectedAccessoryCount === 'number' ? fields.affectedAccessoryCount : undefined;
|
|
2193
|
+
const summary = localize(this.log, summaryKey);
|
|
2194
|
+
const action = localize(this.log, actionKey);
|
|
2195
|
+
const affected = names?.length
|
|
2196
|
+
? localize(this.log, 'log.condition.affectedNamed', { names: names.join(', ') })
|
|
2197
|
+
: affectedAccessoryCount
|
|
2198
|
+
? localize(this.log, affectedAccessoryCount === 1 ? 'log.condition.affectedOne' : 'log.condition.affectedMany', { count: affectedAccessoryCount })
|
|
2199
|
+
: '';
|
|
2200
|
+
const message = localize(this.log, active ? 'log.condition.active' : 'log.condition.recovered', {
|
|
2201
|
+
action,
|
|
2202
|
+
affected,
|
|
2203
|
+
summary,
|
|
2204
|
+
});
|
|
2205
|
+
this.log[level](`[${code}] ${message}`);
|
|
2206
|
+
this.log.debug?.(JSON.stringify({
|
|
2207
|
+
scope: 'diagnostic-condition',
|
|
2208
|
+
level,
|
|
2209
|
+
code,
|
|
2210
|
+
active,
|
|
2211
|
+
reason,
|
|
2212
|
+
summaryKey,
|
|
2213
|
+
actionKey,
|
|
2214
|
+
...fields,
|
|
2215
|
+
}));
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
//# sourceMappingURL=diagnostics.js.map
|