@homebridge-plugins/homebridge-eufy-security 4.7.1 → 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.
Files changed (469) hide show
  1. package/AUTHORS +4 -0
  2. package/CODE_OF_CONDUCT.md +30 -0
  3. package/CONTRIBUTING.md +77 -0
  4. package/README.md +152 -49
  5. package/SECURITY.md +30 -0
  6. package/config.schema.json +92 -3
  7. package/dist/account/ownership.js +290 -0
  8. package/dist/account/ownership.js.map +1 -0
  9. package/dist/account/persistence.js +274 -0
  10. package/dist/account/persistence.js.map +1 -0
  11. package/dist/account/temporary-authentication.js +348 -0
  12. package/dist/account/temporary-authentication.js.map +1 -0
  13. package/dist/configuration.js +167 -0
  14. package/dist/configuration.js.map +1 -0
  15. package/dist/device/member-evidence.js +66 -0
  16. package/dist/device/member-evidence.js.map +1 -0
  17. package/dist/device/member-trust.js +114 -0
  18. package/dist/device/member-trust.js.map +1 -0
  19. package/dist/device/snapshot.js +190 -0
  20. package/dist/device/snapshot.js.map +1 -0
  21. package/dist/diagnostics.js +2218 -0
  22. package/dist/diagnostics.js.map +1 -0
  23. package/dist/homekit/adapter.js +2 -0
  24. package/dist/homekit/adapter.js.map +1 -0
  25. package/dist/homekit/adapters/battery.js +208 -0
  26. package/dist/homekit/adapters/battery.js.map +1 -0
  27. package/dist/homekit/adapters/camera-controls.js +527 -0
  28. package/dist/homekit/adapters/camera-controls.js.map +1 -0
  29. package/dist/homekit/adapters/camera-streaming.js +1515 -0
  30. package/dist/homekit/adapters/camera-streaming.js.map +1 -0
  31. package/dist/homekit/adapters/contact.js +138 -0
  32. package/dist/homekit/adapters/contact.js.map +1 -0
  33. package/dist/homekit/adapters/doorbell.js +57 -0
  34. package/dist/homekit/adapters/doorbell.js.map +1 -0
  35. package/dist/homekit/adapters/information.js +55 -0
  36. package/dist/homekit/adapters/information.js.map +1 -0
  37. package/dist/homekit/adapters/lock.js +489 -0
  38. package/dist/homekit/adapters/lock.js.map +1 -0
  39. package/dist/homekit/adapters/motion.js +100 -0
  40. package/dist/homekit/adapters/motion.js.map +1 -0
  41. package/dist/homekit/adapters/registry.js +26 -0
  42. package/dist/homekit/adapters/registry.js.map +1 -0
  43. package/dist/homekit/adapters/security-system.js +425 -0
  44. package/dist/homekit/adapters/security-system.js.map +1 -0
  45. package/dist/homekit/adapters/siren.js +136 -0
  46. package/dist/homekit/adapters/siren.js.map +1 -0
  47. package/dist/homekit/adapters/smart-light.js +662 -0
  48. package/dist/homekit/adapters/smart-light.js.map +1 -0
  49. package/dist/homekit/coverage-matrix.js +119 -0
  50. package/dist/homekit/coverage-matrix.js.map +1 -0
  51. package/dist/homekit/device-control.js +175 -0
  52. package/dist/homekit/device-control.js.map +1 -0
  53. package/dist/homekit/reconciler.js +319 -0
  54. package/dist/homekit/reconciler.js.map +1 -0
  55. package/dist/homekit/representation.js +23 -0
  56. package/dist/homekit/representation.js.map +1 -0
  57. package/dist/index.js +4 -9
  58. package/dist/index.js.map +1 -1
  59. package/dist/media/contracts.js +2 -0
  60. package/dist/media/contracts.js.map +1 -0
  61. package/dist/media/last-successful-image.js +208 -0
  62. package/dist/media/last-successful-image.js.map +1 -0
  63. package/dist/media/live-stream.js +1003 -0
  64. package/dist/media/live-stream.js.map +1 -0
  65. package/dist/media/recording.js +560 -0
  66. package/dist/media/recording.js.map +1 -0
  67. package/dist/media/session-budget.js +39 -0
  68. package/dist/media/session-budget.js.map +1 -0
  69. package/dist/media/snapshot.js +406 -0
  70. package/dist/media/snapshot.js.map +1 -0
  71. package/dist/media/station-live-sessions.js +101 -0
  72. package/dist/media/station-live-sessions.js.map +1 -0
  73. package/dist/platform.js +97 -769
  74. package/dist/platform.js.map +1 -1
  75. package/dist/runtime/channel.js +375 -0
  76. package/dist/runtime/channel.js.map +1 -0
  77. package/dist/runtime/owner.js +682 -0
  78. package/dist/runtime/owner.js.map +1 -0
  79. package/dist/runtime/sdk-client.js +211 -0
  80. package/dist/runtime/sdk-client.js.map +1 -0
  81. package/dist/runtime/tracker.js +211 -0
  82. package/dist/runtime/tracker.js.map +1 -0
  83. package/dist/settings.js +1 -43
  84. package/dist/settings.js.map +1 -1
  85. package/dist/storage.js +59 -0
  86. package/dist/storage.js.map +1 -0
  87. package/dist/ui/dashboard.js +181 -0
  88. package/dist/ui/dashboard.js.map +1 -0
  89. package/dist/ui/device-image.js +22 -0
  90. package/dist/ui/device-image.js.map +1 -0
  91. package/dist/ui/runtime-channel-client.js +305 -0
  92. package/dist/ui/runtime-channel-client.js.map +1 -0
  93. package/dist/ui/server.js +259 -0
  94. package/dist/ui/server.js.map +1 -0
  95. package/homebridge-ui/public/app.css +1902 -0
  96. package/homebridge-ui/public/assets/devices/clean/clean-T1250.webp +0 -0
  97. package/homebridge-ui/public/assets/devices/clean/clean-T2070.webp +0 -0
  98. package/homebridge-ui/public/assets/devices/clean/clean-T2080.webp +0 -0
  99. package/homebridge-ui/public/assets/devices/clean/clean-T2081.webp +0 -0
  100. package/homebridge-ui/public/assets/devices/clean/clean-T2103.webp +0 -0
  101. package/homebridge-ui/public/assets/devices/clean/clean-T2117.webp +0 -0
  102. package/homebridge-ui/public/assets/devices/clean/clean-T2118.webp +0 -0
  103. package/homebridge-ui/public/assets/devices/clean/clean-T2119.webp +0 -0
  104. package/homebridge-ui/public/assets/devices/clean/clean-T211A.webp +0 -0
  105. package/homebridge-ui/public/assets/devices/clean/clean-T2120.webp +0 -0
  106. package/homebridge-ui/public/assets/devices/clean/clean-T2123.webp +0 -0
  107. package/homebridge-ui/public/assets/devices/clean/clean-T2128.webp +0 -0
  108. package/homebridge-ui/public/assets/devices/clean/clean-T2130.webp +0 -0
  109. package/homebridge-ui/public/assets/devices/clean/clean-T2132.webp +0 -0
  110. package/homebridge-ui/public/assets/devices/clean/clean-T2150.webp +0 -0
  111. package/homebridge-ui/public/assets/devices/clean/clean-T2181.webp +0 -0
  112. package/homebridge-ui/public/assets/devices/clean/clean-T2182.webp +0 -0
  113. package/homebridge-ui/public/assets/devices/clean/clean-T2190.webp +0 -0
  114. package/homebridge-ui/public/assets/devices/clean/clean-T2192.webp +0 -0
  115. package/homebridge-ui/public/assets/devices/clean/clean-T2193.webp +0 -0
  116. package/homebridge-ui/public/assets/devices/clean/clean-T2194.webp +0 -0
  117. package/homebridge-ui/public/assets/devices/clean/clean-T2210.webp +0 -0
  118. package/homebridge-ui/public/assets/devices/clean/clean-T2250.webp +0 -0
  119. package/homebridge-ui/public/assets/devices/clean/clean-T2251.webp +0 -0
  120. package/homebridge-ui/public/assets/devices/clean/clean-T2252.webp +0 -0
  121. package/homebridge-ui/public/assets/devices/clean/clean-T2253.webp +0 -0
  122. package/homebridge-ui/public/assets/devices/clean/clean-T2254.webp +0 -0
  123. package/homebridge-ui/public/assets/devices/clean/clean-T2255.webp +0 -0
  124. package/homebridge-ui/public/assets/devices/clean/clean-T2256.webp +0 -0
  125. package/homebridge-ui/public/assets/devices/clean/clean-T2257.webp +0 -0
  126. package/homebridge-ui/public/assets/devices/clean/clean-T2258.webp +0 -0
  127. package/homebridge-ui/public/assets/devices/clean/clean-T2259.webp +0 -0
  128. package/homebridge-ui/public/assets/devices/clean/clean-T2261.webp +0 -0
  129. package/homebridge-ui/public/assets/devices/clean/clean-T2262.webp +0 -0
  130. package/homebridge-ui/public/assets/devices/clean/clean-T2266.webp +0 -0
  131. package/homebridge-ui/public/assets/devices/clean/clean-T2267.webp +0 -0
  132. package/homebridge-ui/public/assets/devices/clean/clean-T2268.webp +0 -0
  133. package/homebridge-ui/public/assets/devices/clean/clean-T2270.webp +0 -0
  134. package/homebridge-ui/public/assets/devices/clean/clean-T2272.webp +0 -0
  135. package/homebridge-ui/public/assets/devices/clean/clean-T2273.webp +0 -0
  136. package/homebridge-ui/public/assets/devices/clean/clean-T2276.webp +0 -0
  137. package/homebridge-ui/public/assets/devices/clean/clean-T2277.webp +0 -0
  138. package/homebridge-ui/public/assets/devices/clean/clean-T2278.webp +0 -0
  139. package/homebridge-ui/public/assets/devices/clean/clean-T2280.webp +0 -0
  140. package/homebridge-ui/public/assets/devices/clean/clean-T2292.webp +0 -0
  141. package/homebridge-ui/public/assets/devices/clean/clean-T2320.webp +0 -0
  142. package/homebridge-ui/public/assets/devices/clean/clean-T2351.webp +0 -0
  143. package/homebridge-ui/public/assets/devices/clean/clean-T2352.webp +0 -0
  144. package/homebridge-ui/public/assets/devices/clean/clean-T2353.webp +0 -0
  145. package/homebridge-ui/public/assets/devices/life/life-T8L00.webp +0 -0
  146. package/homebridge-ui/public/assets/devices/life/life-T8L01.webp +0 -0
  147. package/homebridge-ui/public/assets/devices/life/life-T8L02.webp +0 -0
  148. package/homebridge-ui/public/assets/devices/life/life-T8L04.webp +0 -0
  149. package/homebridge-ui/public/assets/devices/life/life-T8L10.webp +0 -0
  150. package/homebridge-ui/public/assets/devices/life/life-T8L20.webp +0 -0
  151. package/homebridge-ui/public/assets/devices/life/life-T8L30.webp +0 -0
  152. package/homebridge-ui/public/assets/devices/life/life-T8L40.webp +0 -0
  153. package/homebridge-ui/public/assets/devices/mower/mower-T2801.webp +0 -0
  154. package/homebridge-ui/public/assets/devices/mower/mower-T280B.webp +0 -0
  155. package/homebridge-ui/public/assets/devices/mower/mower-T2880.webp +0 -0
  156. package/homebridge-ui/public/assets/devices/security/security-T7400.webp +0 -0
  157. package/homebridge-ui/public/assets/devices/security/security-T7401.webp +0 -0
  158. package/homebridge-ui/public/assets/devices/security/security-T8001.webp +0 -0
  159. package/homebridge-ui/public/assets/devices/security/security-T8002.webp +0 -0
  160. package/homebridge-ui/public/assets/devices/security/security-T8010.webp +0 -0
  161. package/homebridge-ui/public/assets/devices/security/security-T8021.webp +0 -0
  162. package/homebridge-ui/public/assets/devices/security/security-T8023.webp +0 -0
  163. package/homebridge-ui/public/assets/devices/security/security-T8025.webp +0 -0
  164. package/homebridge-ui/public/assets/devices/security/security-T8030.webp +0 -0
  165. package/homebridge-ui/public/assets/devices/security/security-T8110.webp +0 -0
  166. package/homebridge-ui/public/assets/devices/security/security-T8111.webp +0 -0
  167. package/homebridge-ui/public/assets/devices/security/security-T8112.webp +0 -0
  168. package/homebridge-ui/public/assets/devices/security/security-T8113.webp +0 -0
  169. package/homebridge-ui/public/assets/devices/security/security-T8114.webp +0 -0
  170. package/homebridge-ui/public/assets/devices/security/security-T8122.webp +0 -0
  171. package/homebridge-ui/public/assets/devices/security/security-T8123.webp +0 -0
  172. package/homebridge-ui/public/assets/devices/security/security-T8124.webp +0 -0
  173. package/homebridge-ui/public/assets/devices/security/security-T8130.webp +0 -0
  174. package/homebridge-ui/public/assets/devices/security/security-T8131.webp +0 -0
  175. package/homebridge-ui/public/assets/devices/security/security-T8134.webp +0 -0
  176. package/homebridge-ui/public/assets/devices/security/security-T8140.webp +0 -0
  177. package/homebridge-ui/public/assets/devices/security/security-T8141.webp +0 -0
  178. package/homebridge-ui/public/assets/devices/security/security-T8150.webp +0 -0
  179. package/homebridge-ui/public/assets/devices/security/security-T8151.webp +0 -0
  180. package/homebridge-ui/public/assets/devices/security/security-T8152.webp +0 -0
  181. package/homebridge-ui/public/assets/devices/security/security-T8153.webp +0 -0
  182. package/homebridge-ui/public/assets/devices/security/security-T8160.webp +0 -0
  183. package/homebridge-ui/public/assets/devices/security/security-T8161.webp +0 -0
  184. package/homebridge-ui/public/assets/devices/security/security-T8162.webp +0 -0
  185. package/homebridge-ui/public/assets/devices/security/security-T8170.webp +0 -0
  186. package/homebridge-ui/public/assets/devices/security/security-T8171.webp +0 -0
  187. package/homebridge-ui/public/assets/devices/security/security-T8172.webp +0 -0
  188. package/homebridge-ui/public/assets/devices/security/security-T8173.webp +0 -0
  189. package/homebridge-ui/public/assets/devices/security/security-T8200.webp +0 -0
  190. package/homebridge-ui/public/assets/devices/security/security-T8200X.webp +0 -0
  191. package/homebridge-ui/public/assets/devices/security/security-T8201.webp +0 -0
  192. package/homebridge-ui/public/assets/devices/security/security-T8203.webp +0 -0
  193. package/homebridge-ui/public/assets/devices/security/security-T8210.webp +0 -0
  194. package/homebridge-ui/public/assets/devices/security/security-T8213.webp +0 -0
  195. package/homebridge-ui/public/assets/devices/security/security-T8214.webp +0 -0
  196. package/homebridge-ui/public/assets/devices/security/security-T8222.webp +0 -0
  197. package/homebridge-ui/public/assets/devices/security/security-T8400.webp +0 -0
  198. package/homebridge-ui/public/assets/devices/security/security-T8401.webp +0 -0
  199. package/homebridge-ui/public/assets/devices/security/security-T8410.webp +0 -0
  200. package/homebridge-ui/public/assets/devices/security/security-T8411.webp +0 -0
  201. package/homebridge-ui/public/assets/devices/security/security-T8414.webp +0 -0
  202. package/homebridge-ui/public/assets/devices/security/security-T8416.webp +0 -0
  203. package/homebridge-ui/public/assets/devices/security/security-T8417.webp +0 -0
  204. package/homebridge-ui/public/assets/devices/security/security-T8419.webp +0 -0
  205. package/homebridge-ui/public/assets/devices/security/security-T8419N.webp +0 -0
  206. package/homebridge-ui/public/assets/devices/security/security-T8420.webp +0 -0
  207. package/homebridge-ui/public/assets/devices/security/security-T8420X.webp +0 -0
  208. package/homebridge-ui/public/assets/devices/security/security-T8422.webp +0 -0
  209. package/homebridge-ui/public/assets/devices/security/security-T8423.webp +0 -0
  210. package/homebridge-ui/public/assets/devices/security/security-T8424.webp +0 -0
  211. package/homebridge-ui/public/assets/devices/security/security-T8425.webp +0 -0
  212. package/homebridge-ui/public/assets/devices/security/security-T8441.webp +0 -0
  213. package/homebridge-ui/public/assets/devices/security/security-T8442.webp +0 -0
  214. package/homebridge-ui/public/assets/devices/security/security-T8452.webp +0 -0
  215. package/homebridge-ui/public/assets/devices/security/security-T84A0.webp +0 -0
  216. package/homebridge-ui/public/assets/devices/security/security-T84A1.webp +0 -0
  217. package/homebridge-ui/public/assets/devices/security/security-T8500.webp +0 -0
  218. package/homebridge-ui/public/assets/devices/security/security-T8502.webp +0 -0
  219. package/homebridge-ui/public/assets/devices/security/security-T8503.webp +0 -0
  220. package/homebridge-ui/public/assets/devices/security/security-T8504.webp +0 -0
  221. package/homebridge-ui/public/assets/devices/security/security-T8506.webp +0 -0
  222. package/homebridge-ui/public/assets/devices/security/security-T8510.webp +0 -0
  223. package/homebridge-ui/public/assets/devices/security/security-T8510P.webp +0 -0
  224. package/homebridge-ui/public/assets/devices/security/security-T8520.webp +0 -0
  225. package/homebridge-ui/public/assets/devices/security/security-T8520P.webp +0 -0
  226. package/homebridge-ui/public/assets/devices/security/security-T8530.webp +0 -0
  227. package/homebridge-ui/public/assets/devices/security/security-T8531.webp +0 -0
  228. package/homebridge-ui/public/assets/devices/security/security-T85D0.webp +0 -0
  229. package/homebridge-ui/public/assets/devices/security/security-T85L0.webp +0 -0
  230. package/homebridge-ui/public/assets/devices/security/security-T85P0.webp +0 -0
  231. package/homebridge-ui/public/assets/devices/security/security-T85V0.webp +0 -0
  232. package/homebridge-ui/public/assets/devices/security/security-T8600.webp +0 -0
  233. package/homebridge-ui/public/assets/devices/security/security-T86P2.webp +0 -0
  234. package/homebridge-ui/public/assets/devices/security/security-T8790.webp +0 -0
  235. package/homebridge-ui/public/assets/devices/security/security-T87B0.webp +0 -0
  236. package/homebridge-ui/public/assets/devices/security/security-T87B2.webp +0 -0
  237. package/homebridge-ui/public/assets/devices/security/security-T8900.webp +0 -0
  238. package/homebridge-ui/public/assets/devices/security/security-T8910.webp +0 -0
  239. package/homebridge-ui/public/assets/devices/security/security-T8920.webp +0 -0
  240. package/homebridge-ui/public/assets/devices/security/security-T8960.webp +0 -0
  241. package/homebridge-ui/public/assets/devices/security/security-T8B0.webp +0 -0
  242. package/homebridge-ui/public/assets/devices/security/security-T8E00.webp +0 -0
  243. package/homebridge-ui/public/assets/devices/security/security-T8N00.webp +0 -0
  244. package/homebridge-ui/public/assets/devices/security/security-T8W11C.webp +0 -0
  245. package/homebridge-ui/public/assets/devices/security/security-T90F0.webp +0 -0
  246. package/homebridge-ui/public/assets/devices/security/security-T90M0.webp +0 -0
  247. package/homebridge-ui/public/assets/devices/security/security-T90R0.webp +0 -0
  248. package/homebridge-ui/public/assets/devices/security/security-T90S0.webp +0 -0
  249. package/homebridge-ui/public/assets/icons/inventory.svg +1 -1
  250. package/homebridge-ui/public/assets/icons/troubleshoot.svg +1 -0
  251. package/homebridge-ui/public/assets/icons/warning.svg +1 -1
  252. package/homebridge-ui/public/assets/logo-dark.svg +10 -0
  253. package/homebridge-ui/public/assets/logo.svg +10 -0
  254. package/homebridge-ui/public/i18n/en.json +218 -0
  255. package/homebridge-ui/public/i18n/fr.json +218 -0
  256. package/homebridge-ui/public/index.html +377 -28
  257. package/homebridge-ui/public/js/app.js +1065 -0
  258. package/homebridge-ui/public/js/dashboard.js +385 -0
  259. package/homebridge-ui/public/js/legacy-settings.js +37 -0
  260. package/homebridge-ui/public/js/profile-wizard.js +68 -0
  261. package/homebridge-ui/server.js +2 -1393
  262. package/i18n/runtime/en.json +100 -0
  263. package/media/Snapshot-Unavailable.jpg +0 -0
  264. package/media/Snapshot-black.jpg +0 -0
  265. package/media/camera-disabled.jpg +0 -0
  266. package/media/camera-offline.jpg +0 -0
  267. package/package.json +29 -24
  268. package/.claude/CLAUDE.md +0 -175
  269. package/.claude/PRD.md +0 -241
  270. package/.claude/docs/debug-recording-lessons.md +0 -91
  271. package/.claude/docs/hksv-recording-fix.md +0 -160
  272. package/.claude/skills/architect/SKILL.md +0 -76
  273. package/.claude/skills/developer/SKILL.md +0 -59
  274. package/.claude/skills/new-device-support/SKILL.md +0 -212
  275. package/.claude/skills/new-device-support/check-device.mjs +0 -363
  276. package/.claude/skills/new-device-support/map-properties.mjs +0 -450
  277. package/.claude/skills/new-device-support/verify-device.mjs +0 -272
  278. package/.claude/skills/planner/SKILL.md +0 -100
  279. package/.claude/skills/qa/SKILL.md +0 -79
  280. package/.claude/skills/support/SKILL.md +0 -175
  281. package/.prettierignore +0 -7
  282. package/.prettierrc.json +0 -7
  283. package/FUNDING.yml +0 -1
  284. package/dist/accessories/AutoSyncStationAccessory.js +0 -157
  285. package/dist/accessories/AutoSyncStationAccessory.js.map +0 -1
  286. package/dist/accessories/BaseAccessory.js +0 -261
  287. package/dist/accessories/BaseAccessory.js.map +0 -1
  288. package/dist/accessories/CameraAccessory.js +0 -431
  289. package/dist/accessories/CameraAccessory.js.map +0 -1
  290. package/dist/accessories/Device.js +0 -65
  291. package/dist/accessories/Device.js.map +0 -1
  292. package/dist/accessories/EntrySensorAccessory.js +0 -48
  293. package/dist/accessories/EntrySensorAccessory.js.map +0 -1
  294. package/dist/accessories/LockAccessory.js +0 -142
  295. package/dist/accessories/LockAccessory.js.map +0 -1
  296. package/dist/accessories/MotionSensorAccessory.js +0 -48
  297. package/dist/accessories/MotionSensorAccessory.js.map +0 -1
  298. package/dist/accessories/SmartDropAccessory.js +0 -141
  299. package/dist/accessories/SmartDropAccessory.js.map +0 -1
  300. package/dist/accessories/StationAccessory.js +0 -373
  301. package/dist/accessories/StationAccessory.js.map +0 -1
  302. package/dist/config.js +0 -26
  303. package/dist/config.js.map +0 -1
  304. package/dist/controller/DebugRecordingManager.js +0 -402
  305. package/dist/controller/DebugRecordingManager.js.map +0 -1
  306. package/dist/controller/LocalLivestreamManager.js +0 -371
  307. package/dist/controller/LocalLivestreamManager.js.map +0 -1
  308. package/dist/controller/recordingDelegate.js +0 -252
  309. package/dist/controller/recordingDelegate.js.map +0 -1
  310. package/dist/controller/snapshotDelegate.js +0 -348
  311. package/dist/controller/snapshotDelegate.js.map +0 -1
  312. package/dist/controller/streamingDelegate.js +0 -389
  313. package/dist/controller/streamingDelegate.js.map +0 -1
  314. package/dist/interfaces.js +0 -2
  315. package/dist/interfaces.js.map +0 -1
  316. package/dist/media/Snapshot-Unavailable.png +0 -0
  317. package/dist/media/Snapshot-Unavailable.xcf +0 -0
  318. package/dist/media/Snapshot-black.png +0 -0
  319. package/dist/media/camera-disabled.png +0 -0
  320. package/dist/media/camera-offline.png +0 -0
  321. package/dist/media/media/Snapshot-Unavailable.png +0 -0
  322. package/dist/media/media/Snapshot-Unavailable.xcf +0 -0
  323. package/dist/media/media/Snapshot-black.png +0 -0
  324. package/dist/media/media/camera-disabled.png +0 -0
  325. package/dist/media/media/camera-offline.png +0 -0
  326. package/dist/utils/Talkback.js +0 -162
  327. package/dist/utils/Talkback.js.map +0 -1
  328. package/dist/utils/accessoriesStore.js +0 -207
  329. package/dist/utils/accessoriesStore.js.map +0 -1
  330. package/dist/utils/configTypes.js +0 -36
  331. package/dist/utils/configTypes.js.map +0 -1
  332. package/dist/utils/ffmpeg.js +0 -1111
  333. package/dist/utils/ffmpeg.js.map +0 -1
  334. package/dist/utils/interfaces.js +0 -8
  335. package/dist/utils/interfaces.js.map +0 -1
  336. package/dist/utils/utils.js +0 -224
  337. package/dist/utils/utils.js.map +0 -1
  338. package/dist/version.js +0 -2
  339. package/dist/version.js.map +0 -1
  340. package/homebridge-eufy-security.png +0 -0
  341. package/homebridge-ui/diagnosticsCrypto.js +0 -112
  342. package/homebridge-ui/public/app.js +0 -237
  343. package/homebridge-ui/public/assets/devices/4g_lte_cam_s330_large.png +0 -0
  344. package/homebridge-ui/public/assets/devices/4g_lte_starlight_large.jpg +0 -0
  345. package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C30.png +0 -0
  346. package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C31.png +0 -0
  347. package/homebridge-ui/public/assets/devices/batterydoorbell1080p_large.jpg +0 -0
  348. package/homebridge-ui/public/assets/devices/batterydoorbell2k_large.png +0 -0
  349. package/homebridge-ui/public/assets/devices/batterydoorbell2kdual_large.jpg +0 -0
  350. package/homebridge-ui/public/assets/devices/batterydoorbell_e340_large.png +0 -0
  351. package/homebridge-ui/public/assets/devices/eufy-security-client.png +0 -0
  352. package/homebridge-ui/public/assets/devices/eufyCamS4_large.png +0 -0
  353. package/homebridge-ui/public/assets/devices/eufycam2_large.png +0 -0
  354. package/homebridge-ui/public/assets/devices/eufycam2c_large.jpg +0 -0
  355. package/homebridge-ui/public/assets/devices/eufycam2cpro_large.jpg +0 -0
  356. package/homebridge-ui/public/assets/devices/eufycam2pro_large.jpg +0 -0
  357. package/homebridge-ui/public/assets/devices/eufycam3_large.jpg +0 -0
  358. package/homebridge-ui/public/assets/devices/eufycam3c_large.jpg +0 -0
  359. package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
  360. package/homebridge-ui/public/assets/devices/eufycam_large.jpg +0 -0
  361. package/homebridge-ui/public/assets/devices/eufycame330_large.jpg +0 -0
  362. package/homebridge-ui/public/assets/devices/flood_freeze_sensor_e20_t90F0_large.png +0 -0
  363. package/homebridge-ui/public/assets/devices/floodlight2_large.jpg +0 -0
  364. package/homebridge-ui/public/assets/devices/floodlight2pro_large.jpg +0 -0
  365. package/homebridge-ui/public/assets/devices/floodlight_large.jpg +0 -0
  366. package/homebridge-ui/public/assets/devices/floodlightcame340_large.jpg +0 -0
  367. package/homebridge-ui/public/assets/devices/garage_camera_t8452_large.png +0 -0
  368. package/homebridge-ui/public/assets/devices/homebase2_large.png +0 -0
  369. package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
  370. package/homebridge-ui/public/assets/devices/homebase_large.jpg +0 -0
  371. package/homebridge-ui/public/assets/devices/homebasemini_large.png +0 -0
  372. package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
  373. package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
  374. package/homebridge-ui/public/assets/devices/indoorcamC220_t8419N_large.png +0 -0
  375. package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
  376. package/homebridge-ui/public/assets/devices/indoorcamc120_large.png +0 -0
  377. package/homebridge-ui/public/assets/devices/indoorcammini_large.jpg +0 -0
  378. package/homebridge-ui/public/assets/devices/indoorcamp24_large.png +0 -0
  379. package/homebridge-ui/public/assets/devices/indoorcams350_large.png +0 -0
  380. package/homebridge-ui/public/assets/devices/keypad_large.png +0 -0
  381. package/homebridge-ui/public/assets/devices/minibase_chime_T8023_large.jpg +0 -0
  382. package/homebridge-ui/public/assets/devices/motionsensor_large.png +0 -0
  383. package/homebridge-ui/public/assets/devices/nvr_s4_max_T8N00_large.png +0 -0
  384. package/homebridge-ui/public/assets/devices/pir_sensor_e20_large.png +0 -0
  385. package/homebridge-ui/public/assets/devices/poe_bullet_ptz_cam_s4_T8E00_large.png +0 -0
  386. package/homebridge-ui/public/assets/devices/sensor_large.png +0 -0
  387. package/homebridge-ui/public/assets/devices/siren_sensor_e20_large.png +0 -0
  388. package/homebridge-ui/public/assets/devices/smartdrop_t8790_large.png +0 -0
  389. package/homebridge-ui/public/assets/devices/smartlock_t8500_large.png +0 -0
  390. package/homebridge-ui/public/assets/devices/smartlock_t8500_wifibridge_large.jpg +0 -0
  391. package/homebridge-ui/public/assets/devices/smartlock_t8503_large.png +0 -0
  392. package/homebridge-ui/public/assets/devices/smartlock_t8504_large.jpg +0 -0
  393. package/homebridge-ui/public/assets/devices/smartlock_t8510P_t8520P_large.png +0 -0
  394. package/homebridge-ui/public/assets/devices/smartlock_t85D0_large.png +0 -0
  395. package/homebridge-ui/public/assets/devices/smartlock_t85L0_large.png +0 -0
  396. package/homebridge-ui/public/assets/devices/smartlock_t85P0_large.png +0 -0
  397. package/homebridge-ui/public/assets/devices/smartlock_t85V0_large.png +0 -0
  398. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8502_large.png +0 -0
  399. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8506_large.png +0 -0
  400. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8520_large.png +0 -0
  401. package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_large.png +0 -0
  402. package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_wifibridge_large.jpg +0 -0
  403. package/homebridge-ui/public/assets/devices/smartlock_video_t8530_large.png +0 -0
  404. package/homebridge-ui/public/assets/devices/smartlock_video_t8531_large.png +0 -0
  405. package/homebridge-ui/public/assets/devices/smartlockwifibridge_t8021_large.jpg +0 -0
  406. package/homebridge-ui/public/assets/devices/smartsafe_s10_t7400_large.png +0 -0
  407. package/homebridge-ui/public/assets/devices/smartsafe_s12_t7401_large.png +0 -0
  408. package/homebridge-ui/public/assets/devices/smarttrack_card_t87B2_large.png +0 -0
  409. package/homebridge-ui/public/assets/devices/smarttrack_link_t87B0_large.png +0 -0
  410. package/homebridge-ui/public/assets/devices/smoke_alarm_e10_t90S0_large.png +0 -0
  411. package/homebridge-ui/public/assets/devices/solocamc210_large.jpg +0 -0
  412. package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
  413. package/homebridge-ui/public/assets/devices/solocame20_large.jpg +0 -0
  414. package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
  415. package/homebridge-ui/public/assets/devices/solocame40_large.jpg +0 -0
  416. package/homebridge-ui/public/assets/devices/solocame42_large.png +0 -0
  417. package/homebridge-ui/public/assets/devices/solocaml20_large.jpg +0 -0
  418. package/homebridge-ui/public/assets/devices/solocams220_large.jpg +0 -0
  419. package/homebridge-ui/public/assets/devices/solocams340_large.png +0 -0
  420. package/homebridge-ui/public/assets/devices/solocams40_large.jpg +0 -0
  421. package/homebridge-ui/public/assets/devices/soloindoorcamc24_large.jpg +0 -0
  422. package/homebridge-ui/public/assets/devices/solooutdoorcamc22_large.png +0 -0
  423. package/homebridge-ui/public/assets/devices/solooutdoorcamc24_large.jpg +0 -0
  424. package/homebridge-ui/public/assets/devices/unknown.png +0 -0
  425. package/homebridge-ui/public/assets/devices/walllight_s100_large.png +0 -0
  426. package/homebridge-ui/public/assets/devices/walllight_s120_large.png +0 -0
  427. package/homebridge-ui/public/assets/devices/water_freeze_sensor_t8920_large.png +0 -0
  428. package/homebridge-ui/public/assets/devices/wireddoorbell1080p_large.jpg +0 -0
  429. package/homebridge-ui/public/assets/devices/wireddoorbell2k_large.png +0 -0
  430. package/homebridge-ui/public/assets/devices/wireddoorbelldual_large.jpg +0 -0
  431. package/homebridge-ui/public/assets/icons/attach.svg +0 -1
  432. package/homebridge-ui/public/assets/icons/battery_0.svg +0 -1
  433. package/homebridge-ui/public/assets/icons/battery_1.svg +0 -1
  434. package/homebridge-ui/public/assets/icons/battery_2.svg +0 -1
  435. package/homebridge-ui/public/assets/icons/battery_3.svg +0 -1
  436. package/homebridge-ui/public/assets/icons/battery_4.svg +0 -1
  437. package/homebridge-ui/public/assets/icons/battery_5.svg +0 -1
  438. package/homebridge-ui/public/assets/icons/battery_6.svg +0 -1
  439. package/homebridge-ui/public/assets/icons/copy.svg +0 -1
  440. package/homebridge-ui/public/assets/icons/delete.svg +0 -1
  441. package/homebridge-ui/public/assets/icons/download.svg +0 -1
  442. package/homebridge-ui/public/assets/icons/info.svg +0 -1
  443. package/homebridge-ui/public/assets/icons/refresh.svg +0 -1
  444. package/homebridge-ui/public/assets/icons/satellite_alt.svg +0 -1
  445. package/homebridge-ui/public/assets/icons/solar_power.svg +0 -1
  446. package/homebridge-ui/public/components/device-card.js +0 -174
  447. package/homebridge-ui/public/components/guard-modes.js +0 -88
  448. package/homebridge-ui/public/components/number-input.js +0 -121
  449. package/homebridge-ui/public/components/select.js +0 -73
  450. package/homebridge-ui/public/components/toggle.js +0 -68
  451. package/homebridge-ui/public/services/api.js +0 -250
  452. package/homebridge-ui/public/services/config.js +0 -144
  453. package/homebridge-ui/public/style.css +0 -818
  454. package/homebridge-ui/public/utils/countries.js +0 -73
  455. package/homebridge-ui/public/utils/device-images.js +0 -102
  456. package/homebridge-ui/public/utils/helpers.js +0 -115
  457. package/homebridge-ui/public/views/dashboard.js +0 -226
  458. package/homebridge-ui/public/views/device-detail.js +0 -626
  459. package/homebridge-ui/public/views/diagnostics.js +0 -402
  460. package/homebridge-ui/public/views/login.js +0 -650
  461. package/homebridge-ui/public/views/recordings.js +0 -319
  462. package/homebridge-ui/public/views/settings.js +0 -197
  463. package/homebridge-ui/public/views/unsupported-detail.js +0 -296
  464. package/media/Snapshot-Unavailable.png +0 -0
  465. package/media/Snapshot-Unavailable.xcf +0 -0
  466. package/media/Snapshot-black.png +0 -0
  467. package/media/camera-disabled.png +0 -0
  468. package/media/camera-offline.png +0 -0
  469. 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