@mp-consulting/homebridge-daikin-cloud 1.3.6 → 1.3.8

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 (291) hide show
  1. package/LICENSE +39 -1
  2. package/README.md +3 -1
  3. package/dist/src/accessories/air-conditioning-accessory.d.ts +2 -2
  4. package/dist/src/accessories/air-conditioning-accessory.d.ts.map +1 -1
  5. package/dist/src/accessories/air-conditioning-accessory.js.map +1 -1
  6. package/dist/src/accessories/altherma-accessory.d.ts +2 -2
  7. package/dist/src/accessories/altherma-accessory.d.ts.map +1 -1
  8. package/dist/src/accessories/altherma-accessory.js.map +1 -1
  9. package/dist/src/accessories/base-accessory.d.ts +5 -5
  10. package/dist/src/accessories/base-accessory.d.ts.map +1 -1
  11. package/dist/src/accessories/base-accessory.js +7 -4
  12. package/dist/src/accessories/base-accessory.js.map +1 -1
  13. package/dist/src/api/daikin-api.d.ts +25 -25
  14. package/dist/src/api/daikin-api.d.ts.map +1 -1
  15. package/dist/src/api/daikin-api.js +41 -31
  16. package/dist/src/api/daikin-api.js.map +1 -1
  17. package/dist/src/api/daikin-cloud.repository.d.ts.map +1 -1
  18. package/dist/src/api/daikin-cloud.repository.js.map +1 -1
  19. package/dist/src/api/daikin-controller.d.ts +41 -42
  20. package/dist/src/api/daikin-controller.d.ts.map +1 -1
  21. package/dist/src/api/daikin-controller.js +39 -39
  22. package/dist/src/api/daikin-controller.js.map +1 -1
  23. package/dist/src/api/daikin-device.d.ts +33 -31
  24. package/dist/src/api/daikin-device.d.ts.map +1 -1
  25. package/dist/src/api/daikin-device.js +40 -29
  26. package/dist/src/api/daikin-device.js.map +1 -1
  27. package/dist/src/api/daikin-mobile-oauth.d.ts +16 -16
  28. package/dist/src/api/daikin-mobile-oauth.d.ts.map +1 -1
  29. package/dist/src/api/daikin-mobile-oauth.js +32 -22
  30. package/dist/src/api/daikin-mobile-oauth.js.map +1 -1
  31. package/dist/src/api/daikin-oauth.d.ts +29 -29
  32. package/dist/src/api/daikin-oauth.d.ts.map +1 -1
  33. package/dist/src/api/daikin-oauth.js +45 -35
  34. package/dist/src/api/daikin-oauth.js.map +1 -1
  35. package/dist/src/api/daikin-schemas.d.ts +4 -4
  36. package/dist/src/api/daikin-schemas.js +3 -3
  37. package/dist/src/api/daikin-schemas.js.map +1 -1
  38. package/dist/src/api/daikin-types.js.map +1 -1
  39. package/dist/src/api/daikin-websocket.d.ts +31 -32
  40. package/dist/src/api/daikin-websocket.d.ts.map +1 -1
  41. package/dist/src/api/daikin-websocket.js +30 -30
  42. package/dist/src/api/daikin-websocket.js.map +1 -1
  43. package/dist/src/api/index.d.ts +1 -1
  44. package/dist/src/api/index.d.ts.map +1 -1
  45. package/dist/src/api/index.js +2 -1
  46. package/dist/src/api/index.js.map +1 -1
  47. package/dist/src/api/token-storage.d.ts +1 -1
  48. package/dist/src/api/token-storage.d.ts.map +1 -1
  49. package/dist/src/api/token-storage.js +20 -11
  50. package/dist/src/api/token-storage.js.map +1 -1
  51. package/dist/src/config/config-manager.d.ts +33 -33
  52. package/dist/src/config/config-manager.d.ts.map +1 -1
  53. package/dist/src/config/config-manager.js +33 -33
  54. package/dist/src/config/config-manager.js.map +1 -1
  55. package/dist/src/constants/api.constants.js.map +1 -1
  56. package/dist/src/device/accessory-factory.d.ts +10 -10
  57. package/dist/src/device/accessory-factory.d.ts.map +1 -1
  58. package/dist/src/device/accessory-factory.js +6 -6
  59. package/dist/src/device/accessory-factory.js.map +1 -1
  60. package/dist/src/device/capability-detector.d.ts +8 -8
  61. package/dist/src/device/capability-detector.d.ts.map +1 -1
  62. package/dist/src/device/capability-detector.js +6 -6
  63. package/dist/src/device/capability-detector.js.map +1 -1
  64. package/dist/src/device/capability-docs.d.ts +1 -1
  65. package/dist/src/device/capability-docs.d.ts.map +1 -1
  66. package/dist/src/device/capability-docs.js +1 -2
  67. package/dist/src/device/capability-docs.js.map +1 -1
  68. package/dist/src/device/profiles/device-profile.d.ts +1 -1
  69. package/dist/src/device/profiles/device-profile.d.ts.map +1 -1
  70. package/dist/src/device/profiles/device-profile.js +4 -4
  71. package/dist/src/device/profiles/device-profile.js.map +1 -1
  72. package/dist/src/features/base-feature.d.ts +2 -2
  73. package/dist/src/features/base-feature.d.ts.map +1 -1
  74. package/dist/src/features/base-feature.js +2 -3
  75. package/dist/src/features/base-feature.js.map +1 -1
  76. package/dist/src/features/feature-manager.d.ts +8 -8
  77. package/dist/src/features/feature-manager.d.ts.map +1 -1
  78. package/dist/src/features/feature-manager.js +5 -5
  79. package/dist/src/features/feature-manager.js.map +1 -1
  80. package/dist/src/features/modes/dry-operation-mode.feature.d.ts +1 -1
  81. package/dist/src/features/modes/dry-operation-mode.feature.d.ts.map +1 -1
  82. package/dist/src/features/modes/dry-operation-mode.feature.js.map +1 -1
  83. package/dist/src/features/modes/econo-mode.feature.d.ts +1 -1
  84. package/dist/src/features/modes/econo-mode.feature.d.ts.map +1 -1
  85. package/dist/src/features/modes/econo-mode.feature.js.map +1 -1
  86. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts +1 -1
  87. package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts.map +1 -1
  88. package/dist/src/features/modes/fan-only-operation-mode.feature.js.map +1 -1
  89. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts +1 -1
  90. package/dist/src/features/modes/indoor-silent-mode.feature.d.ts.map +1 -1
  91. package/dist/src/features/modes/indoor-silent-mode.feature.js.map +1 -1
  92. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts +1 -1
  93. package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts.map +1 -1
  94. package/dist/src/features/modes/outdoor-silent-mode.feature.js.map +1 -1
  95. package/dist/src/features/modes/powerful-mode.feature.d.ts +1 -1
  96. package/dist/src/features/modes/powerful-mode.feature.d.ts.map +1 -1
  97. package/dist/src/features/modes/powerful-mode.feature.js.map +1 -1
  98. package/dist/src/features/modes/streamer-mode.feature.d.ts +1 -1
  99. package/dist/src/features/modes/streamer-mode.feature.d.ts.map +1 -1
  100. package/dist/src/features/modes/streamer-mode.feature.js.map +1 -1
  101. package/dist/src/index.d.ts +1 -1
  102. package/dist/src/index.d.ts.map +1 -1
  103. package/dist/src/index.js.map +1 -1
  104. package/dist/src/platform.d.ts +6 -5
  105. package/dist/src/platform.d.ts.map +1 -1
  106. package/dist/src/platform.js +2 -2
  107. package/dist/src/platform.js.map +1 -1
  108. package/dist/src/services/climate-control.service.d.ts +8 -2
  109. package/dist/src/services/climate-control.service.d.ts.map +1 -1
  110. package/dist/src/services/climate-control.service.js +53 -59
  111. package/dist/src/services/climate-control.service.js.map +1 -1
  112. package/dist/src/services/hot-water-tank.service.d.ts +6 -2
  113. package/dist/src/services/hot-water-tank.service.d.ts.map +1 -1
  114. package/dist/src/services/hot-water-tank.service.js +31 -34
  115. package/dist/src/services/hot-water-tank.service.js.map +1 -1
  116. package/dist/src/types/daikin-enums.js +12 -12
  117. package/dist/src/types/daikin-enums.js.map +1 -1
  118. package/dist/src/types/device-capabilities.d.ts +1 -1
  119. package/dist/src/types/device-capabilities.d.ts.map +1 -1
  120. package/dist/src/utils/log-context.d.ts +23 -23
  121. package/dist/src/utils/log-context.d.ts.map +1 -1
  122. package/dist/src/utils/log-context.js +28 -28
  123. package/dist/src/utils/log-context.js.map +1 -1
  124. package/dist/src/utils/strings.d.ts.map +1 -1
  125. package/dist/src/utils/strings.js.map +1 -1
  126. package/dist/src/utils/update-mapper.d.ts +16 -16
  127. package/dist/src/utils/update-mapper.d.ts.map +1 -1
  128. package/dist/src/utils/update-mapper.js +14 -14
  129. package/dist/src/utils/update-mapper.js.map +1 -1
  130. package/homebridge-ui/public/index.html +24 -24
  131. package/homebridge-ui/public/lib/kit.css +253 -0
  132. package/homebridge-ui/public/lib/kit.js +133 -0
  133. package/homebridge-ui/public/script.js +957 -898
  134. package/homebridge-ui/public/styles.css +0 -1
  135. package/homebridge-ui/server.js +739 -695
  136. package/package.json +30 -25
  137. package/.claude/settings.json +0 -3
  138. package/.claude/settings.local.json +0 -24
  139. package/CHANGELOG.md +0 -114
  140. package/CLAUDE.md +0 -269
  141. package/config.md +0 -2
  142. package/docs/ARCHITECTURE.md +0 -645
  143. package/docs/IMPLEMENTATION_GUIDE.md +0 -899
  144. package/docs/IMPROVEMENTS_SUMMARY.md +0 -415
  145. package/docs/NEXT_STEPS.md +0 -368
  146. package/docs/Screenshot 2024-07-04 at 18.41.28.png +0 -0
  147. package/docs/TROUBLESHOOTING.md +0 -475
  148. package/docs/api-response-for-BRP069A8x.json +0 -520
  149. package/docs/api-response-for-BRP069C4x-2.json +0 -881
  150. package/docs/api-response-for-BRP069C4x.json +0 -916
  151. package/docs/api-response-for-altherma.json +0 -759
  152. package/docs/api-response-for-altherma2.json +0 -2735
  153. package/docs/api-response-with-multiple-devices-incl-heatpump.json +0 -2544
  154. package/docs/cr-insance-altherma-id-0.json +0 -834
  155. package/docs/mock-air-to-air-dx23.json +0 -759
  156. package/docs/mock-air-to-air-dx4.json +0 -1134
  157. package/docs/mock-airpurifier-with-humidifier.json +0 -732
  158. package/docs/mock-airpurifier.json +0 -450
  159. package/docs/mock-altherma-air-to-water-lan.json +0 -845
  160. package/docs/mock-altherma-air-to-water-wlan.json +0 -845
  161. package/docs/mock-d2cnd-gas-boiler.json +0 -649
  162. package/docs/setpointmode-vs-controlmode-vs-setpoints-vs-sensorydata.txt +0 -6
  163. package/images/fan-speed.jpeg +0 -0
  164. package/images/homekit-controls.jpeg +0 -0
  165. package/images/homekit-settings.jpeg +0 -0
  166. package/images/swing-mode.png +0 -0
  167. package/jest.config.ts +0 -21
  168. package/test/fixtures/altherma-crSense-2.ts +0 -834
  169. package/test/fixtures/altherma-fraction.ts +0 -718
  170. package/test/fixtures/altherma-heat-pump-2.ts +0 -479
  171. package/test/fixtures/altherma-heat-pump.ts +0 -757
  172. package/test/fixtures/altherma-miladcerkic-off.ts +0 -524
  173. package/test/fixtures/altherma-miladcerkic.ts +0 -524
  174. package/test/fixtures/altherma-v1ckoeln.ts +0 -644
  175. package/test/fixtures/altherma-with-embedded-id-zero.ts +0 -834
  176. package/test/fixtures/dx23-airco-2.ts +0 -343
  177. package/test/fixtures/dx23-airco.ts +0 -518
  178. package/test/fixtures/dx4-airco.ts +0 -914
  179. package/test/fixtures/unknown-jan.ts +0 -488
  180. package/test/fixtures/unknown-kitchen-guests.ts +0 -488
  181. package/test/hbConfig/.daikin-mobile-tokenset +0 -8
  182. package/test/hbConfig/.uix-dashboard.json +0 -1
  183. package/test/hbConfig/.uix-secrets +0 -1
  184. package/test/hbConfig/accessories/.cachedAccessories.bak +0 -1
  185. package/test/hbConfig/accessories/cachedAccessories +0 -1
  186. package/test/hbConfig/accessories/uiAccessoriesLayout.json +0 -1
  187. package/test/hbConfig/auth.json +0 -10
  188. package/test/hbConfig/backups/config-backups/config.json.1767953686461 +0 -25
  189. package/test/hbConfig/backups/config-backups/config.json.1767953695236 +0 -29
  190. package/test/hbConfig/backups/config-backups/config.json.1767953814763 +0 -29
  191. package/test/hbConfig/backups/config-backups/config.json.1767953823101 +0 -29
  192. package/test/hbConfig/backups/config-backups/config.json.1767954822835 +0 -29
  193. package/test/hbConfig/backups/config-backups/config.json.1767954859218 +0 -29
  194. package/test/hbConfig/backups/config-backups/config.json.1767960145503 +0 -33
  195. package/test/hbConfig/backups/config-backups/config.json.1767960168068 +0 -44
  196. package/test/hbConfig/backups/config-backups/config.json.1767960170333 +0 -46
  197. package/test/hbConfig/backups/config-backups/config.json.1767960172731 +0 -44
  198. package/test/hbConfig/backups/config-backups/config.json.1767960179323 +0 -44
  199. package/test/hbConfig/backups/config-backups/config.json.1767960182114 +0 -44
  200. package/test/hbConfig/backups/config-backups/config.json.1767960189302 +0 -44
  201. package/test/hbConfig/backups/config-backups/config.json.1767960195194 +0 -44
  202. package/test/hbConfig/backups/config-backups/config.json.1767960197301 +0 -44
  203. package/test/hbConfig/backups/config-backups/config.json.1767960199151 +0 -44
  204. package/test/hbConfig/backups/config-backups/config.json.1767960199667 +0 -44
  205. package/test/hbConfig/backups/config-backups/config.json.1767960329839 +0 -44
  206. package/test/hbConfig/backups/config-backups/config.json.1767960334503 +0 -44
  207. package/test/hbConfig/backups/config-backups/config.json.1767960336208 +0 -44
  208. package/test/hbConfig/backups/config-backups/config.json.1767960338537 +0 -44
  209. package/test/hbConfig/backups/config-backups/config.json.1767963223953 +0 -44
  210. package/test/hbConfig/backups/config-backups/config.json.1767963241753 +0 -44
  211. package/test/hbConfig/backups/config-backups/config.json.1767963252785 +0 -44
  212. package/test/hbConfig/backups/config-backups/config.json.1767963463944 +0 -44
  213. package/test/hbConfig/backups/config-backups/config.json.1767963834475 +0 -44
  214. package/test/hbConfig/backups/config-backups/config.json.1767963838474 +0 -44
  215. package/test/hbConfig/backups/config-backups/config.json.1767963843066 +0 -44
  216. package/test/hbConfig/backups/config-backups/config.json.1767965217715 +0 -44
  217. package/test/hbConfig/backups/config-backups/config.json.1767965419624 +0 -25
  218. package/test/hbConfig/backups/config-backups/config.json.1767965870934 +0 -32
  219. package/test/hbConfig/backups/config-backups/config.json.1767977675045 +0 -32
  220. package/test/hbConfig/backups/config-backups/config.json.1767977677222 +0 -33
  221. package/test/hbConfig/backups/config-backups/config.json.1767977710226 +0 -33
  222. package/test/hbConfig/backups/config-backups/config.json.1767977741397 +0 -33
  223. package/test/hbConfig/backups/config-backups/config.json.1767977977093 +0 -35
  224. package/test/hbConfig/backups/config-backups/config.json.1767977981773 +0 -35
  225. package/test/hbConfig/backups/config-backups/config.json.1767977986514 +0 -35
  226. package/test/hbConfig/backups/config-backups/config.json.1767977991174 +0 -35
  227. package/test/hbConfig/backups/config-backups/config.json.1767979424487 +0 -35
  228. package/test/hbConfig/backups/config-backups/config.json.1767979424987 +0 -35
  229. package/test/hbConfig/backups/config-backups/config.json.1767979432646 +0 -47
  230. package/test/hbConfig/backups/config-backups/config.json.1767979433150 +0 -47
  231. package/test/hbConfig/backups/config-backups/config.json.1767979436933 +0 -47
  232. package/test/hbConfig/backups/config-backups/config.json.1767979437438 +0 -47
  233. package/test/hbConfig/backups/config-backups/config.json.1767979441676 +0 -47
  234. package/test/hbConfig/backups/config-backups/config.json.1767979442180 +0 -47
  235. package/test/hbConfig/backups/config-backups/config.json.1767979466735 +0 -47
  236. package/test/hbConfig/backups/config-backups/config.json.1767979903636 +0 -47
  237. package/test/hbConfig/backups/config-backups/config.json.1767979904135 +0 -47
  238. package/test/hbConfig/backups/config-backups/config.json.1767979906606 +0 -47
  239. package/test/hbConfig/backups/config-backups/config.json.1767979907108 +0 -47
  240. package/test/hbConfig/backups/config-backups/config.json.1767988702341 +0 -47
  241. package/test/hbConfig/backups/config-backups/config.json.1767988702837 +0 -47
  242. package/test/hbConfig/backups/config-backups/config.json.1767988713159 +0 -47
  243. package/test/hbConfig/backups/config-backups/config.json.1767988713664 +0 -47
  244. package/test/hbConfig/backups/config-backups/config.json.1767988918139 +0 -47
  245. package/test/hbConfig/backups/config-backups/config.json.1767988918639 +0 -47
  246. package/test/hbConfig/backups/config-backups/config.json.1767988921120 +0 -47
  247. package/test/hbConfig/backups/config-backups/config.json.1767988921624 +0 -47
  248. package/test/hbConfig/backups/config-backups/config.json.1767988930307 +0 -47
  249. package/test/hbConfig/backups/config-backups/config.json.1767988935070 +0 -47
  250. package/test/hbConfig/backups/config-backups/config.json.1767988935574 +0 -47
  251. package/test/hbConfig/backups/config-backups/config.json.1767989710262 +0 -47
  252. package/test/hbConfig/backups/config-backups/config.json.1767989710760 +0 -47
  253. package/test/hbConfig/backups/config-backups/config.json.1767989729668 +0 -47
  254. package/test/hbConfig/backups/config-backups/config.json.1767990295225 +0 -47
  255. package/test/hbConfig/backups/config-backups/config.json.1767990479921 +0 -47
  256. package/test/hbConfig/backups/config-backups/config.json.1767990481702 +0 -49
  257. package/test/hbConfig/backups/instance-backups/homebridge-backup-1E4A432551BA.1768010187391.tar.gz +0 -0
  258. package/test/hbConfig/backups/instance-backups/homebridge-backup-1E4A432551BA.1768096587387.tar.gz +0 -0
  259. package/test/hbConfig/backups/instance-backups/homebridge-backup-1E4A432551BA.1768182987404.tar.gz +0 -0
  260. package/test/hbConfig/config.json +0 -47
  261. package/test/hbConfig/daikin-cloud-certs/server.crt +0 -22
  262. package/test/hbConfig/daikin-cloud-certs/server.key +0 -28
  263. package/test/hbConfig/persist/AccessoryInfo.1E4A432551BA.json +0 -1
  264. package/test/hbConfig/persist/IdentifierCache.1E4A432551BA.json +0 -1
  265. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14758 +0 -1
  266. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14759 +0 -1
  267. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14760 +0 -1
  268. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14761 +0 -1
  269. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14762 +0 -1
  270. package/test/hbConfig/tmp/daikin_request/api.onecta.daikineurope.com_01-09-2026-23-29-52/request_14764 +0 -1
  271. package/test/helpers/test-isolation.ts +0 -228
  272. package/test/integration/air-conditioning.test.ts +0 -396
  273. package/test/integration/altherma.test.ts +0 -279
  274. package/test/integration/platform.test.ts +0 -118
  275. package/test/mobile-tokens.json +0 -8
  276. package/test/mocks/index.ts +0 -27
  277. package/test/test-gigya-auth.js +0 -443
  278. package/test/test-mobile-oauth.js +0 -175
  279. package/test/test-websocket-mobile.js +0 -123
  280. package/test/test-websocket.js +0 -116
  281. package/test/unit/api/__snapshots__/daikinCloud.test.ts.snap +0 -1320
  282. package/test/unit/api/daikin-api.test.ts +0 -442
  283. package/test/unit/api/daikin-cloud-repository.test.ts +0 -107
  284. package/test/unit/api/daikin-oauth.test.ts +0 -214
  285. package/test/unit/api/daikinCloud.test.ts +0 -12
  286. package/test/unit/api/token-storage.test.ts +0 -90
  287. package/test/unit/config/config-manager.test.ts +0 -271
  288. package/test/unit/device/daikin-device.test.ts +0 -73
  289. package/test/unit/services/hot-water-tank.service.test.ts +0 -123
  290. package/test/unit/utils/log-context.test.ts +0 -271
  291. package/test/unit/utils/update-mapper.test.ts +0 -404
@@ -12,47 +12,55 @@ const $$ = (selector) => document.querySelectorAll(selector);
12
12
  const $id = (id) => document.getElementById(id);
13
13
 
14
14
  const DOM = {
15
- show: (el) => el?.classList.remove('d-none'),
16
- hide: (el) => el?.classList.add('d-none'),
17
- toggle: (el, show) => el?.classList.toggle('d-none', !show),
18
- setValid: (el, valid) => el?.classList.toggle('is-invalid', !valid),
15
+ show: (el) => el?.classList.remove('d-none'),
16
+ hide: (el) => el?.classList.add('d-none'),
17
+ toggle: (el, show) => el?.classList.toggle('d-none', !show),
18
+ setValid: (el, valid) => el?.classList.toggle('is-invalid', !valid),
19
19
  };
20
20
 
21
21
  const Utils = {
22
- capitalize: (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : '',
23
-
24
- escapeHtml(text) {
25
- const div = document.createElement('div');
26
- div.textContent = text;
27
- return div.innerHTML;
28
- },
29
-
30
- isValidPort(port) {
31
- const num = parseInt(port, 10);
32
- return !isNaN(num) && num >= 1 && num <= 65535;
33
- },
34
-
35
- isValidNumber(value, min, max) {
36
- const num = parseInt(value, 10);
37
- return !isNaN(num) && num >= min && num <= max;
38
- },
39
-
40
- isValidIPv4(ip) {
41
- if (!ip) return true;
42
- return /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)$/.test(ip);
43
- },
44
-
45
- formatTime(ms) {
46
- const days = Math.floor(ms / 86400000);
47
- const hours = Math.floor((ms % 86400000) / 3600000);
48
- const minutes = Math.floor((ms % 3600000) / 60000);
49
- const seconds = Math.floor((ms % 60000) / 1000);
50
-
51
- if (days > 0) return `${days}d ${hours}h ${minutes}m`;
52
- if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
53
- if (minutes > 0) return `${minutes}m ${seconds}s`;
54
- return `${seconds}s`;
55
- },
22
+ capitalize: (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : '',
23
+
24
+ escapeHtml(text) {
25
+ const div = document.createElement('div');
26
+ div.textContent = text;
27
+ return div.innerHTML;
28
+ },
29
+
30
+ isValidPort(port) {
31
+ const num = parseInt(port, 10);
32
+ return !isNaN(num) && num >= 1 && num <= 65535;
33
+ },
34
+
35
+ isValidNumber(value, min, max) {
36
+ const num = parseInt(value, 10);
37
+ return !isNaN(num) && num >= min && num <= max;
38
+ },
39
+
40
+ isValidIPv4(ip) {
41
+ if (!ip) {
42
+ return true;
43
+ }
44
+ return /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)$/.test(ip);
45
+ },
46
+
47
+ formatTime(ms) {
48
+ const days = Math.floor(ms / 86400000);
49
+ const hours = Math.floor((ms % 86400000) / 3600000);
50
+ const minutes = Math.floor((ms % 3600000) / 60000);
51
+ const seconds = Math.floor((ms % 60000) / 1000);
52
+
53
+ if (days > 0) {
54
+ return `${days}d ${hours}h ${minutes}m`;
55
+ }
56
+ if (hours > 0) {
57
+ return `${hours}h ${minutes}m ${seconds}s`;
58
+ }
59
+ if (minutes > 0) {
60
+ return `${minutes}m ${seconds}s`;
61
+ }
62
+ return `${seconds}s`;
63
+ },
56
64
  };
57
65
 
58
66
  // ============================================================================
@@ -60,17 +68,17 @@ const Utils = {
60
68
  // ============================================================================
61
69
 
62
70
  const State = {
63
- currentTab: 'devices',
64
- currentStep: 1,
65
- authState: null,
66
- authUrl: null,
67
- tokenExpiresAt: null,
68
- isValidating: false,
69
- intervals: {
70
- poll: null,
71
- countdown: null,
72
- statusRefresh: null,
73
- },
71
+ currentTab: 'devices',
72
+ currentStep: 1,
73
+ authState: null,
74
+ authUrl: null,
75
+ tokenExpiresAt: null,
76
+ isValidating: false,
77
+ intervals: {
78
+ poll: null,
79
+ countdown: null,
80
+ statusRefresh: null,
81
+ },
74
82
  };
75
83
 
76
84
  // Element cache (populated on init)
@@ -81,27 +89,29 @@ let El = {};
81
89
  // ============================================================================
82
90
 
83
91
  const UI = {
84
- showLoading: () => DOM.show(El.loading),
85
- hideLoading: () => DOM.hide(El.loading),
86
-
87
- showError(message) {
88
- if (El.globalError) {
89
- El.globalError.innerHTML = `<div class="alert alert-danger">${Utils.escapeHtml(message)}</div>`;
90
- DOM.show(El.globalError);
91
- setTimeout(() => DOM.hide(El.globalError), 10000);
92
- }
93
- },
94
-
95
- setButtonLoading(btn, loading, loadingText = 'Loading...', normalText = null) {
96
- if (!btn) return;
97
- btn.disabled = loading;
98
- if (loading) {
99
- btn.dataset.originalText = btn.innerHTML;
100
- btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> ${loadingText}`;
101
- } else {
102
- btn.innerHTML = normalText || btn.dataset.originalText || btn.innerHTML;
103
- }
104
- },
92
+ showLoading: () => DOM.show(El.loading),
93
+ hideLoading: () => DOM.hide(El.loading),
94
+
95
+ showError(message) {
96
+ if (El.globalError) {
97
+ El.globalError.innerHTML = `<div class="alert alert-danger">${Utils.escapeHtml(message)}</div>`;
98
+ DOM.show(El.globalError);
99
+ setTimeout(() => DOM.hide(El.globalError), 10000);
100
+ }
101
+ },
102
+
103
+ setButtonLoading(btn, loading, loadingText = 'Loading...', normalText = null) {
104
+ if (!btn) {
105
+ return;
106
+ }
107
+ btn.disabled = loading;
108
+ if (loading) {
109
+ btn.dataset.originalText = btn.innerHTML;
110
+ btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> ${loadingText}`;
111
+ } else {
112
+ btn.innerHTML = normalText || btn.dataset.originalText || btn.innerHTML;
113
+ }
114
+ },
105
115
  };
106
116
 
107
117
  // ============================================================================
@@ -109,18 +119,18 @@ const UI = {
109
119
  // ============================================================================
110
120
 
111
121
  function switchTab(tabName) {
112
- State.currentTab = tabName;
113
- $$('.nav-pills .nav-link').forEach(btn =>
114
- btn.classList.toggle('active', btn.dataset.tab === tabName));
115
- $$('.tab-pane').forEach(panel =>
116
- panel.classList.toggle('active', panel.id === `tab-${tabName}`));
122
+ State.currentTab = tabName;
123
+ $$('.nav-pills .nav-link').forEach(btn =>
124
+ btn.classList.toggle('active', btn.dataset.tab === tabName));
125
+ $$('.tab-pane').forEach(panel =>
126
+ panel.classList.toggle('active', panel.id === `tab-${tabName}`));
117
127
  }
118
128
 
119
129
  function switchSettingsSubtab(subtabName) {
120
- $$('.nav-tabs .nav-link').forEach(btn =>
121
- btn.classList.toggle('active', btn.dataset.subtab === subtabName));
122
- $$('.settings-subtab-content').forEach(panel =>
123
- DOM.toggle(panel, panel.id === `subtab-${subtabName}`));
130
+ $$('.nav-tabs .nav-link').forEach(btn =>
131
+ btn.classList.toggle('active', btn.dataset.subtab === subtabName));
132
+ $$('.settings-subtab-content').forEach(panel =>
133
+ DOM.toggle(panel, panel.id === `subtab-${subtabName}`));
124
134
  }
125
135
 
126
136
  // ============================================================================
@@ -128,43 +138,47 @@ function switchSettingsSubtab(subtabName) {
128
138
  // ============================================================================
129
139
 
130
140
  const Countdown = {
131
- start() {
132
- this.stop();
133
- this.update();
134
- State.intervals.countdown = setInterval(() => this.update(), 1000);
135
- },
136
-
137
- stop() {
138
- if (State.intervals.countdown) {
139
- clearInterval(State.intervals.countdown);
140
- State.intervals.countdown = null;
141
- }
142
- },
143
-
144
- update() {
145
- if (!State.tokenExpiresAt) return;
146
-
147
- const diff = State.tokenExpiresAt - Date.now();
148
-
149
- if (diff <= 0) {
150
- [El.tokenExpires, El.authTokenExpires].forEach(el => {
151
- if (el) el.textContent = 'Expired';
152
- });
153
- this.stop();
154
- Auth.loadStatus();
155
- return;
141
+ start() {
142
+ this.stop();
143
+ this.update();
144
+ State.intervals.countdown = setInterval(() => this.update(), 1000);
145
+ },
146
+
147
+ stop() {
148
+ if (State.intervals.countdown) {
149
+ clearInterval(State.intervals.countdown);
150
+ State.intervals.countdown = null;
151
+ }
152
+ },
153
+
154
+ update() {
155
+ if (!State.tokenExpiresAt) {
156
+ return;
157
+ }
158
+
159
+ const diff = State.tokenExpiresAt - Date.now();
160
+
161
+ if (diff <= 0) {
162
+ [El.tokenExpires, El.authTokenExpires].forEach(el => {
163
+ if (el) {
164
+ el.textContent = 'Expired';
156
165
  }
157
-
158
- const formatted = Utils.formatTime(diff);
159
- const color = diff < 300000 ? 'var(--bs-danger)' : diff < 1800000 ? 'var(--bs-warning)' : '';
160
-
161
- [El.tokenExpires, El.authTokenExpires].forEach(el => {
162
- if (el) {
163
- el.textContent = formatted;
164
- el.style.color = color;
165
- }
166
- });
167
- },
166
+ });
167
+ this.stop();
168
+ Auth.loadStatus();
169
+ return;
170
+ }
171
+
172
+ const formatted = Utils.formatTime(diff);
173
+ const color = diff < 300000 ? 'var(--bs-danger)' : diff < 1800000 ? 'var(--bs-warning)' : '';
174
+
175
+ [El.tokenExpires, El.authTokenExpires].forEach(el => {
176
+ if (el) {
177
+ el.textContent = formatted;
178
+ el.style.color = color;
179
+ }
180
+ });
181
+ },
168
182
  };
169
183
 
170
184
  // ============================================================================
@@ -172,141 +186,141 @@ const Countdown = {
172
186
  // ============================================================================
173
187
 
174
188
  const Auth = {
175
- STATUS_REFRESH_INTERVAL: 60000,
176
- _lastStatusCheck: 0,
177
- _statusPromise: null,
178
-
179
- async loadStatus() {
180
- // Debounce: skip if called within 500ms of last call
181
- const now = Date.now();
182
- if (now - this._lastStatusCheck < 500 && this._statusPromise) {
183
- return this._statusPromise;
184
- }
185
- this._lastStatusCheck = now;
186
-
187
- this._statusPromise = (async () => {
188
- try {
189
- const response = await homebridge.request('/auth/status');
190
- this.updateUI(response);
191
- } catch (error) {
192
- this.updateUI({ authenticated: false, error: error.message });
193
- }
194
- })();
195
-
196
- return this._statusPromise;
197
- },
198
-
199
- startStatusRefresh() {
200
- this.stopStatusRefresh();
201
- State.intervals.statusRefresh = setInterval(() => this.checkTokenRenewal(), this.STATUS_REFRESH_INTERVAL);
202
- },
203
-
204
- stopStatusRefresh() {
205
- if (State.intervals.statusRefresh) {
206
- clearInterval(State.intervals.statusRefresh);
207
- State.intervals.statusRefresh = null;
208
- }
209
- },
210
-
211
- async checkTokenRenewal() {
212
- try {
213
- const response = await homebridge.request('/auth/status');
214
- if (response.authenticated && response.expiresAt) {
215
- const newExpires = new Date(response.expiresAt).getTime();
216
- if (!State.tokenExpiresAt || newExpires !== State.tokenExpiresAt.getTime()) {
217
- this.updateUI(response);
218
- }
219
- }
220
- } catch (error) {
221
- console.error('Token check failed:', error);
222
- }
223
- },
224
-
225
- updateUI(status) {
226
- if (status.authenticated) {
227
- this.setAuthenticated(status);
228
- } else {
229
- this.setUnauthenticated(status);
230
- }
231
- },
232
-
233
- setAuthenticated(status) {
234
- const isExpired = status.isExpired;
235
-
236
- El.statusBadge.className = `badge ${isExpired ? 'expired' : 'authenticated'}`;
237
- El.statusIndicator.className = `status-dot ${isExpired ? 'yellow' : 'green'}`;
238
- El.statusText.textContent = isExpired ? 'Expired' : 'Connected';
239
- El.authStatus.textContent = isExpired
240
- ? (status.canRefresh ? 'Token expired (will auto-refresh)' : 'Token expired')
241
- : 'Authenticated and ready';
242
-
243
- if (status.expiresAt) {
244
- State.tokenExpiresAt = new Date(status.expiresAt);
245
- DOM.show(El.tokenExpiresLabel);
246
- DOM.show(El.expiresRow);
247
- Countdown.start();
248
- }
249
-
250
- this.startStatusRefresh();
251
- El.btnAuthenticate.textContent = 'Re-authenticate';
252
- DOM.show(El.btnRevoke);
253
- El.btnTest.disabled = false;
254
- },
255
-
256
- setUnauthenticated(status) {
257
- El.statusBadge.className = 'badge not-authenticated';
258
- El.statusIndicator.className = 'status-dot red';
259
- El.statusText.textContent = 'Not Connected';
260
- El.authStatus.textContent = status.error || 'Authentication required';
261
-
262
- DOM.hide(El.tokenExpiresLabel);
263
- DOM.hide(El.expiresRow);
264
- El.tokenExpires.textContent = '';
265
- El.btnAuthenticate.textContent = 'Authenticate';
266
- DOM.hide(El.btnRevoke);
267
- El.btnTest.disabled = true;
268
-
269
- Countdown.stop();
270
- this.stopStatusRefresh();
271
- },
272
-
273
- async startAuth(config) {
274
- const result = await homebridge.request('/auth/start', config);
275
- State.authState = result.state;
276
- State.authUrl = result.authUrl;
277
- return result;
278
- },
279
-
280
- async revoke() {
281
- UI.showLoading();
282
- try {
283
- const config = await homebridge.getPluginConfig();
284
- const { clientId, clientSecret } = config[0] || {};
285
- await homebridge.request('/auth/revoke', { clientId, clientSecret });
286
- this.loadStatus();
287
- } catch (error) {
288
- UI.showError('Failed to revoke: ' + error.message);
289
- }
290
- UI.hideLoading();
291
- },
292
-
293
- async testConnection() {
294
- UI.setButtonLoading(El.btnTest, true, 'Testing...');
295
- DOM.hide(El.testResult);
296
-
297
- try {
298
- const result = await homebridge.request('/auth/test');
299
- El.testResult.className = `alert ${result.success ? 'alert-success' : 'alert-danger'}`;
300
- El.testResult.textContent = result.message;
301
- DOM.show(El.testResult);
302
- } catch (error) {
303
- El.testResult.className = 'alert alert-danger';
304
- El.testResult.textContent = 'Test failed: ' + error.message;
305
- DOM.show(El.testResult);
189
+ STATUS_REFRESH_INTERVAL: 60000,
190
+ _lastStatusCheck: 0,
191
+ _statusPromise: null,
192
+
193
+ async loadStatus() {
194
+ // Debounce: skip if called within 500ms of last call
195
+ const now = Date.now();
196
+ if (now - this._lastStatusCheck < 500 && this._statusPromise) {
197
+ return this._statusPromise;
198
+ }
199
+ this._lastStatusCheck = now;
200
+
201
+ this._statusPromise = (async () => {
202
+ try {
203
+ const response = await homebridge.request('/auth/status');
204
+ this.updateUI(response);
205
+ } catch (error) {
206
+ this.updateUI({ authenticated: false, error: error.message });
207
+ }
208
+ })();
209
+
210
+ return this._statusPromise;
211
+ },
212
+
213
+ startStatusRefresh() {
214
+ this.stopStatusRefresh();
215
+ State.intervals.statusRefresh = setInterval(() => this.checkTokenRenewal(), this.STATUS_REFRESH_INTERVAL);
216
+ },
217
+
218
+ stopStatusRefresh() {
219
+ if (State.intervals.statusRefresh) {
220
+ clearInterval(State.intervals.statusRefresh);
221
+ State.intervals.statusRefresh = null;
222
+ }
223
+ },
224
+
225
+ async checkTokenRenewal() {
226
+ try {
227
+ const response = await homebridge.request('/auth/status');
228
+ if (response.authenticated && response.expiresAt) {
229
+ const newExpires = new Date(response.expiresAt).getTime();
230
+ if (!State.tokenExpiresAt || newExpires !== State.tokenExpiresAt.getTime()) {
231
+ this.updateUI(response);
306
232
  }
307
-
308
- UI.setButtonLoading(El.btnTest, false, null, 'Test Connection');
309
- },
233
+ }
234
+ } catch (error) {
235
+ console.error('Token check failed:', error);
236
+ }
237
+ },
238
+
239
+ updateUI(status) {
240
+ if (status.authenticated) {
241
+ this.setAuthenticated(status);
242
+ } else {
243
+ this.setUnauthenticated(status);
244
+ }
245
+ },
246
+
247
+ setAuthenticated(status) {
248
+ const isExpired = status.isExpired;
249
+
250
+ El.statusBadge.className = `badge ${isExpired ? 'expired' : 'authenticated'}`;
251
+ El.statusIndicator.className = `status-dot ${isExpired ? 'yellow' : 'green'}`;
252
+ El.statusText.textContent = isExpired ? 'Expired' : 'Connected';
253
+ El.authStatus.textContent = isExpired
254
+ ? (status.canRefresh ? 'Token expired (will auto-refresh)' : 'Token expired')
255
+ : 'Authenticated and ready';
256
+
257
+ if (status.expiresAt) {
258
+ State.tokenExpiresAt = new Date(status.expiresAt);
259
+ DOM.show(El.tokenExpiresLabel);
260
+ DOM.show(El.expiresRow);
261
+ Countdown.start();
262
+ }
263
+
264
+ this.startStatusRefresh();
265
+ El.btnAuthenticate.textContent = 'Re-authenticate';
266
+ DOM.show(El.btnRevoke);
267
+ El.btnTest.disabled = false;
268
+ },
269
+
270
+ setUnauthenticated(status) {
271
+ El.statusBadge.className = 'badge not-authenticated';
272
+ El.statusIndicator.className = 'status-dot red';
273
+ El.statusText.textContent = 'Not Connected';
274
+ El.authStatus.textContent = status.error || 'Authentication required';
275
+
276
+ DOM.hide(El.tokenExpiresLabel);
277
+ DOM.hide(El.expiresRow);
278
+ El.tokenExpires.textContent = '';
279
+ El.btnAuthenticate.textContent = 'Authenticate';
280
+ DOM.hide(El.btnRevoke);
281
+ El.btnTest.disabled = true;
282
+
283
+ Countdown.stop();
284
+ this.stopStatusRefresh();
285
+ },
286
+
287
+ async startAuth(config) {
288
+ const result = await homebridge.request('/auth/start', config);
289
+ State.authState = result.state;
290
+ State.authUrl = result.authUrl;
291
+ return result;
292
+ },
293
+
294
+ async revoke() {
295
+ UI.showLoading();
296
+ try {
297
+ const config = await homebridge.getPluginConfig();
298
+ const { clientId, clientSecret } = config[0] || {};
299
+ await homebridge.request('/auth/revoke', { clientId, clientSecret });
300
+ this.loadStatus();
301
+ } catch (error) {
302
+ UI.showError('Failed to revoke: ' + error.message);
303
+ }
304
+ UI.hideLoading();
305
+ },
306
+
307
+ async testConnection() {
308
+ UI.setButtonLoading(El.btnTest, true, 'Testing...');
309
+ DOM.hide(El.testResult);
310
+
311
+ try {
312
+ const result = await homebridge.request('/auth/test');
313
+ El.testResult.className = `alert ${result.success ? 'alert-success' : 'alert-danger'}`;
314
+ El.testResult.textContent = result.message;
315
+ DOM.show(El.testResult);
316
+ } catch (error) {
317
+ El.testResult.className = 'alert alert-danger';
318
+ El.testResult.textContent = 'Test failed: ' + error.message;
319
+ DOM.show(El.testResult);
320
+ }
321
+
322
+ UI.setButtonLoading(El.btnTest, false, null, 'Test Connection');
323
+ },
310
324
  };
311
325
 
312
326
  // ============================================================================
@@ -314,122 +328,133 @@ const Auth = {
314
328
  // ============================================================================
315
329
 
316
330
  const Wizard = {
317
- show() {
318
- DOM.show(El.wizard);
319
- DOM.hide(El.authStatusCard);
320
- this.goToStep(1);
321
- },
322
-
323
- hide() {
324
- Polling.stop();
325
- homebridge.request('/auth/stop-server').catch(() => {});
326
- DOM.hide(El.wizard);
327
- DOM.show(El.authStatusCard);
328
- DOM.hide(El.callbackServerStatus);
329
- },
330
-
331
- goToStep(step) {
332
- State.currentStep = step;
333
- for (let i = 1; i <= 3; i++) {
334
- const stepEl = $id(`step-${i}`);
335
- const contentEl = $id(`content-${i}`);
336
- stepEl.classList.remove('active', 'completed');
337
- DOM.toggle(contentEl, i === step);
338
- if (i < step) stepEl.classList.add('completed');
339
- else if (i === step) stepEl.classList.add('active');
340
- }
341
- },
342
-
343
- async validateAndProceed() {
344
- if (State.isValidating) return;
345
- State.isValidating = true;
346
-
347
- const config = this.getFormConfig();
348
- const portInput = $id('callbackServerPort');
349
-
350
- if (!Utils.isValidPort(config.callbackServerPort)) {
351
- DOM.setValid(portInput, false);
352
- El.validationErrors.innerHTML = '<div>Port must be between 1 and 65535</div>';
353
- DOM.show(El.validationErrors);
354
- State.isValidating = false;
355
- return;
356
- }
357
- DOM.setValid(portInput, true);
358
-
359
- UI.showLoading();
360
- try {
361
- const validation = await homebridge.request('/config/validate', config);
362
- if (!validation.valid) {
363
- El.validationErrors.innerHTML = validation.errors.map(e => `<div>${e}</div>`).join('');
364
- DOM.show(El.validationErrors);
365
- UI.hideLoading();
366
- State.isValidating = false;
367
- return;
368
- }
369
-
370
- DOM.hide(El.validationErrors);
371
- const authResult = await Auth.startAuth(config);
372
-
373
- El.authUrlDisplay.textContent = authResult.authUrl;
374
- DOM.show(El.authUrlContainer);
375
-
376
- const manualCollapse = $id('manual-callback-collapse');
377
- DOM.toggle(El.callbackServerStatus, authResult.callbackServerRunning);
378
- if (manualCollapse) {
379
- manualCollapse.classList.toggle('show', !authResult.callbackServerRunning);
380
- }
381
-
382
- Polling.start();
383
- this.goToStep(2);
384
- } catch (error) {
385
- El.validationErrors.innerHTML = `<div>${error.message}</div>`;
386
- DOM.show(El.validationErrors);
387
- }
388
-
331
+ show() {
332
+ DOM.show(El.wizard);
333
+ DOM.hide(El.authStatusCard);
334
+ this.goToStep(1);
335
+ },
336
+
337
+ hide() {
338
+ Polling.stop();
339
+ homebridge.request('/auth/stop-server').catch(() => {});
340
+ DOM.hide(El.wizard);
341
+ DOM.show(El.authStatusCard);
342
+ DOM.hide(El.callbackServerStatus);
343
+ },
344
+
345
+ goToStep(step) {
346
+ State.currentStep = step;
347
+ for (let i = 1; i <= 3; i++) {
348
+ const stepEl = $id(`step-${i}`);
349
+ const contentEl = $id(`content-${i}`);
350
+ stepEl.classList.remove('active', 'completed');
351
+ DOM.toggle(contentEl, i === step);
352
+ if (i < step) {
353
+ stepEl.classList.add('completed');
354
+ } else if (i === step) {
355
+ stepEl.classList.add('active');
356
+ }
357
+ }
358
+ },
359
+
360
+ async validateAndProceed() {
361
+ if (State.isValidating) {
362
+ return;
363
+ }
364
+ State.isValidating = true;
365
+
366
+ const config = this.getFormConfig();
367
+ const portInput = $id('callbackServerPort');
368
+
369
+ if (!Utils.isValidPort(config.callbackServerPort)) {
370
+ DOM.setValid(portInput, false);
371
+ El.validationErrors.innerHTML = '<div>Port must be between 1 and 65535</div>';
372
+ DOM.show(El.validationErrors);
373
+ State.isValidating = false;
374
+ return;
375
+ }
376
+ DOM.setValid(portInput, true);
377
+
378
+ UI.showLoading();
379
+ try {
380
+ const validation = await homebridge.request('/config/validate', config);
381
+ if (!validation.valid) {
382
+ El.validationErrors.innerHTML = validation.errors.map(e => `<div>${e}</div>`).join('');
383
+ DOM.show(El.validationErrors);
389
384
  UI.hideLoading();
390
385
  State.isValidating = false;
391
- },
392
-
393
- getFormConfig() {
394
- return {
395
- clientId: $id('clientId')?.value.trim() || '',
396
- clientSecret: $id('clientSecret')?.value.trim() || '',
397
- callbackServerExternalAddress: $id('callbackServerExternalAddress')?.value.trim() || '',
398
- callbackServerPort: $id('callbackServerPort')?.value.trim() || '8582',
399
- };
400
- },
401
-
402
- async submitCallbackUrl() {
403
- const url = $id('callbackUrl')?.value.trim();
404
- if (!url) return UI.showError('Please paste the callback URL');
405
- if (!url.includes('code=')) return UI.showError('Invalid URL - must contain code parameter');
406
-
407
- UI.showLoading();
408
- try {
409
- const result = await homebridge.request('/auth/', { callbackUrl: url });
410
- if (result.success) {
411
- El.successMessage.textContent = result.message;
412
- this.goToStep(3);
413
- await Config.save();
414
- } else {
415
- UI.showError('Authentication failed: ' + (result.message || 'Unknown error'));
416
- }
417
- } catch (error) {
418
- UI.showError('Authentication failed: ' + error.message);
419
- }
420
- UI.hideLoading();
421
- },
422
-
423
- finish() {
424
- Polling.stop();
425
- this.hide();
426
- Auth.loadStatus();
427
- Devices.load();
428
- },
429
-
430
- openAuthUrl() {
431
- if (State.authUrl) window.open(State.authUrl, '_blank');
432
- },
386
+ return;
387
+ }
388
+
389
+ DOM.hide(El.validationErrors);
390
+ const authResult = await Auth.startAuth(config);
391
+
392
+ El.authUrlDisplay.textContent = authResult.authUrl;
393
+ DOM.show(El.authUrlContainer);
394
+
395
+ const manualCollapse = $id('manual-callback-collapse');
396
+ DOM.toggle(El.callbackServerStatus, authResult.callbackServerRunning);
397
+ if (manualCollapse) {
398
+ manualCollapse.classList.toggle('show', !authResult.callbackServerRunning);
399
+ }
400
+
401
+ Polling.start();
402
+ this.goToStep(2);
403
+ } catch (error) {
404
+ El.validationErrors.innerHTML = `<div>${error.message}</div>`;
405
+ DOM.show(El.validationErrors);
406
+ }
407
+
408
+ UI.hideLoading();
409
+ State.isValidating = false;
410
+ },
411
+
412
+ getFormConfig() {
413
+ return {
414
+ clientId: $id('clientId')?.value.trim() || '',
415
+ clientSecret: $id('clientSecret')?.value.trim() || '',
416
+ callbackServerExternalAddress: $id('callbackServerExternalAddress')?.value.trim() || '',
417
+ callbackServerPort: $id('callbackServerPort')?.value.trim() || '8582',
418
+ };
419
+ },
420
+
421
+ async submitCallbackUrl() {
422
+ const url = $id('callbackUrl')?.value.trim();
423
+ if (!url) {
424
+ return UI.showError('Please paste the callback URL');
425
+ }
426
+ if (!url.includes('code=')) {
427
+ return UI.showError('Invalid URL - must contain code parameter');
428
+ }
429
+
430
+ UI.showLoading();
431
+ try {
432
+ const result = await homebridge.request('/auth/', { callbackUrl: url });
433
+ if (result.success) {
434
+ El.successMessage.textContent = result.message;
435
+ this.goToStep(3);
436
+ await Config.save();
437
+ } else {
438
+ UI.showError('Authentication failed: ' + (result.message || 'Unknown error'));
439
+ }
440
+ } catch (error) {
441
+ UI.showError('Authentication failed: ' + error.message);
442
+ }
443
+ UI.hideLoading();
444
+ },
445
+
446
+ finish() {
447
+ Polling.stop();
448
+ this.hide();
449
+ Auth.loadStatus();
450
+ Devices.load();
451
+ },
452
+
453
+ openAuthUrl() {
454
+ if (State.authUrl) {
455
+ window.open(State.authUrl, '_blank');
456
+ }
457
+ },
433
458
  };
434
459
 
435
460
  // ============================================================================
@@ -437,47 +462,49 @@ const Wizard = {
437
462
  // ============================================================================
438
463
 
439
464
  const Polling = {
440
- isPolling: false,
441
-
442
- start() {
465
+ isPolling: false,
466
+
467
+ start() {
468
+ this.stop();
469
+ this.isPolling = true;
470
+ this.poll();
471
+ },
472
+
473
+ stop() {
474
+ this.isPolling = false;
475
+ if (State.intervals.poll) {
476
+ clearTimeout(State.intervals.poll);
477
+ State.intervals.poll = null;
478
+ }
479
+ },
480
+
481
+ async poll() {
482
+ if (!this.isPolling) {
483
+ return;
484
+ }
485
+
486
+ try {
487
+ const result = await homebridge.request('/auth/poll');
488
+ if (!result.pending) {
443
489
  this.stop();
444
- this.isPolling = true;
445
- this.poll();
446
- },
447
-
448
- stop() {
449
- this.isPolling = false;
450
- if (State.intervals.poll) {
451
- clearTimeout(State.intervals.poll);
452
- State.intervals.poll = null;
453
- }
454
- },
455
-
456
- async poll() {
457
- if (!this.isPolling) return;
458
-
459
- try {
460
- const result = await homebridge.request('/auth/poll');
461
- if (!result.pending) {
462
- this.stop();
463
- if (result.success) {
464
- El.successMessage.textContent = result.message || 'Authentication successful!';
465
- Wizard.goToStep(3);
466
- await Config.save();
467
- Devices.load();
468
- } else {
469
- UI.showError('Authentication failed: ' + (result.error || 'Unknown error'));
470
- }
471
- return;
472
- }
473
- } catch (error) {
474
- console.error('Polling error:', error);
475
- }
476
-
477
- if (this.isPolling) {
478
- State.intervals.poll = setTimeout(() => this.poll(), 1500);
490
+ if (result.success) {
491
+ El.successMessage.textContent = result.message || 'Authentication successful!';
492
+ Wizard.goToStep(3);
493
+ await Config.save();
494
+ Devices.load();
495
+ } else {
496
+ UI.showError('Authentication failed: ' + (result.error || 'Unknown error'));
479
497
  }
480
- },
498
+ return;
499
+ }
500
+ } catch (error) {
501
+ console.error('Polling error:', error);
502
+ }
503
+
504
+ if (this.isPolling) {
505
+ State.intervals.poll = setTimeout(() => this.poll(), 1500);
506
+ }
507
+ },
481
508
  };
482
509
 
483
510
  // ============================================================================
@@ -485,51 +512,55 @@ const Polling = {
485
512
  // ============================================================================
486
513
 
487
514
  const Config = {
488
- async load() {
489
- try {
490
- const config = await homebridge.getPluginConfig();
491
- if (config?.[0]) {
492
- this.populateForm(config[0]);
493
- }
494
- await this.prefillServerAddress();
495
- } catch (error) {
496
- console.error('Config load failed:', error);
497
- }
498
- },
499
-
500
- async prefillServerAddress() {
501
- const field = $id('callbackServerExternalAddress');
502
- if (!field || field.value.trim()) return;
503
-
504
- try {
505
- const { primaryIp } = await homebridge.request('/server/info');
506
- if (primaryIp) {
507
- field.value = primaryIp;
508
- field.placeholder = primaryIp;
509
- }
510
- } catch (error) {
511
- console.error('Server IP fetch failed:', error);
512
- }
513
- },
514
-
515
- populateForm(config) {
516
- ['clientId', 'clientSecret', 'callbackServerExternalAddress', 'callbackServerPort'].forEach(field => {
517
- const el = $id(field);
518
- if (el && config[field]) el.value = config[field];
519
- });
520
- },
521
-
522
- async save() {
523
- try {
524
- const config = await homebridge.getPluginConfig();
525
- const platformConfig = config[0] || { platform: 'DaikinCloud' };
526
- Object.assign(platformConfig, Wizard.getFormConfig());
527
- await homebridge.updatePluginConfig([platformConfig]);
528
- await homebridge.savePluginConfig();
529
- } catch (error) {
530
- console.error('Config save failed:', error);
531
- }
532
- },
515
+ async load() {
516
+ try {
517
+ const config = await homebridge.getPluginConfig();
518
+ if (config?.[0]) {
519
+ this.populateForm(config[0]);
520
+ }
521
+ await this.prefillServerAddress();
522
+ } catch (error) {
523
+ console.error('Config load failed:', error);
524
+ }
525
+ },
526
+
527
+ async prefillServerAddress() {
528
+ const field = $id('callbackServerExternalAddress');
529
+ if (!field || field.value.trim()) {
530
+ return;
531
+ }
532
+
533
+ try {
534
+ const { primaryIp } = await homebridge.request('/server/info');
535
+ if (primaryIp) {
536
+ field.value = primaryIp;
537
+ field.placeholder = primaryIp;
538
+ }
539
+ } catch (error) {
540
+ console.error('Server IP fetch failed:', error);
541
+ }
542
+ },
543
+
544
+ populateForm(config) {
545
+ ['clientId', 'clientSecret', 'callbackServerExternalAddress', 'callbackServerPort'].forEach(field => {
546
+ const el = $id(field);
547
+ if (el && config[field]) {
548
+ el.value = config[field];
549
+ }
550
+ });
551
+ },
552
+
553
+ async save() {
554
+ try {
555
+ const config = await homebridge.getPluginConfig();
556
+ const platformConfig = config[0] || { platform: 'DaikinCloud' };
557
+ Object.assign(platformConfig, Wizard.getFormConfig());
558
+ await homebridge.updatePluginConfig([platformConfig]);
559
+ await homebridge.savePluginConfig();
560
+ } catch (error) {
561
+ console.error('Config save failed:', error);
562
+ }
563
+ },
533
564
  };
534
565
 
535
566
  // ============================================================================
@@ -537,133 +568,147 @@ const Config = {
537
568
  // ============================================================================
538
569
 
539
570
  const Settings = {
540
- devices: [],
541
- excludedIds: [],
542
- saveTimeout: null,
543
-
544
- FEATURE_KEYS: [
545
- 'showPowerfulMode', 'showEconoMode', 'showStreamerMode',
546
- 'showOutdoorSilentMode', 'showIndoorSilentMode', 'showDryMode', 'showFanOnlyMode',
547
- ],
548
-
549
- async load() {
550
- try {
551
- const config = await homebridge.getPluginConfig();
552
- if (config?.[0]) {
553
- this.populateForm(config[0]);
554
- this.excludedIds = config[0].excludedDevicesByDeviceId || [];
555
- }
556
- await this.loadDeviceToggles();
557
- this.setupAutoSave();
558
- } catch (error) {
559
- console.error('Settings load failed:', error);
560
- }
561
- },
562
-
563
- setupAutoSave() {
564
- const handler = () => this.autoSave();
565
-
566
- // Feature toggles + WebSocket
567
- [...this.FEATURE_KEYS, 'enableWebSocket'].forEach(id => {
568
- $id(id)?.addEventListener('change', handler);
569
- });
570
-
571
- // Number/text inputs
572
- ['updateIntervalInMinutes', 'forceUpdateDelay', 'oidcCallbackServerBindAddr'].forEach(id => {
573
- const el = $id(id);
574
- el?.addEventListener('change', handler);
575
- el?.addEventListener('input', handler);
576
- });
577
- },
578
-
579
- autoSave() {
580
- clearTimeout(this.saveTimeout);
581
- this.saveTimeout = setTimeout(() => {
582
- if (this.validateInputs()) this.save();
583
- }, 500);
584
- },
585
-
586
- validateInputs() {
587
- const validators = [
588
- ['oidcCallbackServerBindAddr', (v) => Utils.isValidIPv4(v.trim())],
589
- ['updateIntervalInMinutes', (v) => Utils.isValidNumber(v, 1, 60)],
590
- ['forceUpdateDelay', (v) => Utils.isValidNumber(v, 1, 300)],
591
- ];
592
-
593
- return validators.every(([id, validate]) => {
594
- const el = $id(id);
595
- const valid = !el || validate(el.value);
596
- DOM.setValid(el, valid);
597
- return valid;
598
- });
599
- },
600
-
601
- populateForm(config) {
602
- const showAllLegacy = config.showExtraFeatures === true;
603
-
604
- this.FEATURE_KEYS.forEach(key => {
605
- const el = $id(key);
606
- if (el) el.checked = key in config ? config[key] === true : showAllLegacy;
607
- });
608
-
609
- const updateInterval = $id('updateIntervalInMinutes');
610
- if (updateInterval) updateInterval.value = config.updateIntervalInMinutes || 15;
611
-
612
- const forceDelay = $id('forceUpdateDelay');
613
- if (forceDelay) forceDelay.value = Math.round((config.forceUpdateDelay || 60000) / 1000);
614
-
615
- const bindAddr = $id('oidcCallbackServerBindAddr');
616
- if (bindAddr) bindAddr.value = config.oidcCallbackServerBindAddr || '0.0.0.0';
617
-
618
- const enableWS = $id('enableWebSocket');
619
- if (enableWS) enableWS.checked = config.enableWebSocket !== false;
620
- },
621
-
622
- async loadDeviceToggles() {
623
- const loading = $id('device-toggles-loading');
624
- const list = $id('device-toggles-list');
625
- const empty = $id('device-toggles-empty');
626
-
627
- DOM.show(loading);
628
- list.innerHTML = '';
629
- DOM.hide(empty);
630
-
631
- try {
632
- const result = await homebridge.request('/devices/list', { mode: AuthMode.current });
633
- DOM.hide(loading);
634
-
635
- if (!result.success) {
636
- empty.innerHTML = `<p class="mb-0">${result.message || 'No devices found. Authenticate first to see your devices.'}</p>`;
637
- DOM.show(empty);
638
- return;
639
- }
640
-
641
- if (!result.devices.length) {
642
- empty.innerHTML = '<p class="mb-0">No devices found. Authenticate first to see your devices.</p>';
643
- DOM.show(empty);
644
- return;
645
- }
646
-
647
- this.devices = result.devices;
648
- list.innerHTML = this.devices.map((d, i) => this.renderDeviceToggle(d, i)).join('');
649
-
650
- list.addEventListener('change', (e) => {
651
- if (e.target.classList.contains('device-visibility-toggle')) {
652
- const idx = parseInt(e.target.dataset.index, 10);
653
- const device = this.devices[idx];
654
- if (device) this.toggleDevice(device.id, e.target.checked, idx);
655
- }
656
- });
657
- } catch (error) {
658
- DOM.hide(loading);
659
- empty.innerHTML = `<p class="mb-0">Failed to load: ${error.message}</p>`;
660
- DOM.show(empty);
571
+ devices: [],
572
+ excludedIds: [],
573
+ saveTimeout: null,
574
+
575
+ FEATURE_KEYS: [
576
+ 'showPowerfulMode', 'showEconoMode', 'showStreamerMode',
577
+ 'showOutdoorSilentMode', 'showIndoorSilentMode', 'showDryMode', 'showFanOnlyMode',
578
+ ],
579
+
580
+ async load() {
581
+ try {
582
+ const config = await homebridge.getPluginConfig();
583
+ if (config?.[0]) {
584
+ this.populateForm(config[0]);
585
+ this.excludedIds = config[0].excludedDevicesByDeviceId || [];
586
+ }
587
+ await this.loadDeviceToggles();
588
+ this.setupAutoSave();
589
+ } catch (error) {
590
+ console.error('Settings load failed:', error);
591
+ }
592
+ },
593
+
594
+ setupAutoSave() {
595
+ const handler = () => this.autoSave();
596
+
597
+ // Feature toggles + WebSocket
598
+ [...this.FEATURE_KEYS, 'enableWebSocket'].forEach(id => {
599
+ $id(id)?.addEventListener('change', handler);
600
+ });
601
+
602
+ // Number/text inputs
603
+ ['updateIntervalInMinutes', 'forceUpdateDelay', 'oidcCallbackServerBindAddr'].forEach(id => {
604
+ const el = $id(id);
605
+ el?.addEventListener('change', handler);
606
+ el?.addEventListener('input', handler);
607
+ });
608
+ },
609
+
610
+ autoSave() {
611
+ clearTimeout(this.saveTimeout);
612
+ this.saveTimeout = setTimeout(() => {
613
+ if (this.validateInputs()) {
614
+ this.save();
615
+ }
616
+ }, 500);
617
+ },
618
+
619
+ validateInputs() {
620
+ const validators = [
621
+ ['oidcCallbackServerBindAddr', (v) => Utils.isValidIPv4(v.trim())],
622
+ ['updateIntervalInMinutes', (v) => Utils.isValidNumber(v, 1, 60)],
623
+ ['forceUpdateDelay', (v) => Utils.isValidNumber(v, 1, 300)],
624
+ ];
625
+
626
+ return validators.every(([id, validate]) => {
627
+ const el = $id(id);
628
+ const valid = !el || validate(el.value);
629
+ DOM.setValid(el, valid);
630
+ return valid;
631
+ });
632
+ },
633
+
634
+ populateForm(config) {
635
+ const showAllLegacy = config.showExtraFeatures === true;
636
+
637
+ this.FEATURE_KEYS.forEach(key => {
638
+ const el = $id(key);
639
+ if (el) {
640
+ el.checked = key in config ? config[key] === true : showAllLegacy;
641
+ }
642
+ });
643
+
644
+ const updateInterval = $id('updateIntervalInMinutes');
645
+ if (updateInterval) {
646
+ updateInterval.value = config.updateIntervalInMinutes || 15;
647
+ }
648
+
649
+ const forceDelay = $id('forceUpdateDelay');
650
+ if (forceDelay) {
651
+ forceDelay.value = Math.round((config.forceUpdateDelay || 60000) / 1000);
652
+ }
653
+
654
+ const bindAddr = $id('oidcCallbackServerBindAddr');
655
+ if (bindAddr) {
656
+ bindAddr.value = config.oidcCallbackServerBindAddr || '0.0.0.0';
657
+ }
658
+
659
+ const enableWS = $id('enableWebSocket');
660
+ if (enableWS) {
661
+ enableWS.checked = config.enableWebSocket !== false;
662
+ }
663
+ },
664
+
665
+ async loadDeviceToggles() {
666
+ const loading = $id('device-toggles-loading');
667
+ const list = $id('device-toggles-list');
668
+ const empty = $id('device-toggles-empty');
669
+
670
+ DOM.show(loading);
671
+ list.innerHTML = '';
672
+ DOM.hide(empty);
673
+
674
+ try {
675
+ const result = await homebridge.request('/devices/list', { mode: AuthMode.current });
676
+ DOM.hide(loading);
677
+
678
+ if (!result.success) {
679
+ empty.innerHTML = `<p class="mb-0">${result.message || 'No devices found. Authenticate first to see your devices.'}</p>`;
680
+ DOM.show(empty);
681
+ return;
682
+ }
683
+
684
+ if (!result.devices.length) {
685
+ empty.innerHTML = '<p class="mb-0">No devices found. Authenticate first to see your devices.</p>';
686
+ DOM.show(empty);
687
+ return;
688
+ }
689
+
690
+ this.devices = result.devices;
691
+ list.innerHTML = this.devices.map((d, i) => this.renderDeviceToggle(d, i)).join('');
692
+
693
+ list.addEventListener('change', (e) => {
694
+ if (e.target.classList.contains('device-visibility-toggle')) {
695
+ const idx = parseInt(e.target.dataset.index, 10);
696
+ const device = this.devices[idx];
697
+ if (device) {
698
+ this.toggleDevice(device.id, e.target.checked, idx);
699
+ }
661
700
  }
662
- },
663
-
664
- renderDeviceToggle(device, index) {
665
- const visible = !this.excludedIds.includes(device.id);
666
- return `
701
+ });
702
+ } catch (error) {
703
+ DOM.hide(loading);
704
+ empty.innerHTML = `<p class="mb-0">Failed to load: ${error.message}</p>`;
705
+ DOM.show(empty);
706
+ }
707
+ },
708
+
709
+ renderDeviceToggle(device, index) {
710
+ const visible = !this.excludedIds.includes(device.id);
711
+ return `
667
712
  <div class="list-group-item d-flex justify-content-between align-items-center">
668
713
  <div>
669
714
  <div class="fw-medium">${Utils.escapeHtml(device.name)}</div>
@@ -676,66 +721,72 @@ const Settings = {
676
721
  </div>
677
722
  </div>
678
723
  </div>`;
679
- },
680
-
681
- toggleDevice(deviceId, visible, index) {
682
- const label = $(`[data-label-index="${index}"]`);
683
-
684
- if (visible) {
685
- this.excludedIds = this.excludedIds.filter(id => id !== deviceId);
686
- } else if (!this.excludedIds.includes(deviceId)) {
687
- this.excludedIds.push(deviceId);
688
- }
689
-
690
- if (label) {
691
- label.textContent = visible ? 'Visible' : 'Hidden';
692
- label.className = `device-toggle-label small ${visible ? 'visible' : 'hidden-label'}`;
693
- }
724
+ },
725
+
726
+ toggleDevice(deviceId, visible, index) {
727
+ const label = $(`[data-label-index="${index}"]`);
728
+
729
+ if (visible) {
730
+ this.excludedIds = this.excludedIds.filter(id => id !== deviceId);
731
+ } else if (!this.excludedIds.includes(deviceId)) {
732
+ this.excludedIds.push(deviceId);
733
+ }
734
+
735
+ if (label) {
736
+ label.textContent = visible ? 'Visible' : 'Hidden';
737
+ label.className = `device-toggle-label small ${visible ? 'visible' : 'hidden-label'}`;
738
+ }
739
+
740
+ this.autoSave();
741
+ },
742
+
743
+ getFormSettings() {
744
+ const settings = {
745
+ updateIntervalInMinutes: parseInt($id('updateIntervalInMinutes')?.value, 10) || 15,
746
+ forceUpdateDelay: (parseInt($id('forceUpdateDelay')?.value, 10) || 60) * 1000,
747
+ oidcCallbackServerBindAddr: $id('oidcCallbackServerBindAddr')?.value?.trim() || '0.0.0.0',
748
+ excludedDevicesByDeviceId: this.excludedIds,
749
+ enableWebSocket: $id('enableWebSocket')?.checked ?? true,
750
+ };
694
751
 
695
- this.autoSave();
696
- },
697
-
698
- getFormSettings() {
699
- const settings = {
700
- updateIntervalInMinutes: parseInt($id('updateIntervalInMinutes')?.value, 10) || 15,
701
- forceUpdateDelay: (parseInt($id('forceUpdateDelay')?.value, 10) || 60) * 1000,
702
- oidcCallbackServerBindAddr: $id('oidcCallbackServerBindAddr')?.value?.trim() || '0.0.0.0',
703
- excludedDevicesByDeviceId: this.excludedIds,
704
- enableWebSocket: $id('enableWebSocket')?.checked ?? true,
705
- };
706
-
707
- this.FEATURE_KEYS.forEach(key => {
708
- const el = $id(key);
709
- if (el) settings[key] = el.checked;
710
- });
711
-
712
- return settings;
713
- },
714
-
715
- async save() {
716
- const status = $id('settings-status');
717
-
718
- try {
719
- this.showStatus(status, 'saving', 'Saving...');
720
- const config = await homebridge.getPluginConfig();
721
- const platformConfig = config[0] || { platform: 'DaikinCloud' };
722
- Object.assign(platformConfig, this.getFormSettings());
723
- await homebridge.updatePluginConfig([platformConfig]);
724
- await homebridge.savePluginConfig();
725
- this.showStatus(status, 'saved', 'Saved');
726
- } catch (error) {
727
- this.showStatus(status, 'error', 'Failed');
728
- console.error('Settings save failed:', error);
729
- }
730
- },
731
-
732
- showStatus(el, type, message) {
733
- if (!el) return;
734
- el.className = `badge ${type}`;
735
- el.textContent = message;
736
- DOM.show(el);
737
- if (type === 'saved') setTimeout(() => DOM.hide(el), 2000);
738
- },
752
+ this.FEATURE_KEYS.forEach(key => {
753
+ const el = $id(key);
754
+ if (el) {
755
+ settings[key] = el.checked;
756
+ }
757
+ });
758
+
759
+ return settings;
760
+ },
761
+
762
+ async save() {
763
+ const status = $id('settings-status');
764
+
765
+ try {
766
+ this.showStatus(status, 'saving', 'Saving...');
767
+ const config = await homebridge.getPluginConfig();
768
+ const platformConfig = config[0] || { platform: 'DaikinCloud' };
769
+ Object.assign(platformConfig, this.getFormSettings());
770
+ await homebridge.updatePluginConfig([platformConfig]);
771
+ await homebridge.savePluginConfig();
772
+ this.showStatus(status, 'saved', 'Saved');
773
+ } catch (error) {
774
+ this.showStatus(status, 'error', 'Failed');
775
+ console.error('Settings save failed:', error);
776
+ }
777
+ },
778
+
779
+ showStatus(el, type, message) {
780
+ if (!el) {
781
+ return;
782
+ }
783
+ el.className = `badge ${type}`;
784
+ el.textContent = message;
785
+ DOM.show(el);
786
+ if (type === 'saved') {
787
+ setTimeout(() => DOM.hide(el), 2000);
788
+ }
789
+ },
739
790
  };
740
791
 
741
792
  // ============================================================================
@@ -743,56 +794,56 @@ const Settings = {
743
794
  // ============================================================================
744
795
 
745
796
  const Devices = {
746
- async load() {
747
- DOM.show(El.devicesLoading);
748
- El.devicesList.innerHTML = '';
749
- DOM.hide(El.devicesEmpty);
750
- DOM.hide(El.devicesError);
751
-
752
- try {
753
- const result = await homebridge.request('/devices/list', { mode: AuthMode.current });
754
- DOM.hide(El.devicesLoading);
755
-
756
- if (!result.success) {
757
- this.handleError(result);
758
- return;
759
- }
760
-
761
- if (!result.devices.length) {
762
- DOM.show(El.devicesEmpty);
763
- return;
764
- }
765
-
766
- El.devicesList.innerHTML = result.devices.map(d => this.render(d)).join('');
767
- } catch (error) {
768
- DOM.hide(El.devicesLoading);
769
- El.devicesError.textContent = 'Failed to load: ' + error.message;
770
- DOM.show(El.devicesError);
771
- }
772
- },
773
-
774
- handleError(result) {
775
- if (result.message?.includes('Not authenticated')) {
776
- El.devicesEmpty.innerHTML = `
777
- <div class="fs-1 mb-2 opacity-50">🔐</div>
797
+ async load() {
798
+ DOM.show(El.devicesLoading);
799
+ El.devicesList.innerHTML = '';
800
+ DOM.hide(El.devicesEmpty);
801
+ DOM.hide(El.devicesError);
802
+
803
+ try {
804
+ const result = await homebridge.request('/devices/list', { mode: AuthMode.current });
805
+ DOM.hide(El.devicesLoading);
806
+
807
+ if (!result.success) {
808
+ this.handleError(result);
809
+ return;
810
+ }
811
+
812
+ if (!result.devices.length) {
813
+ DOM.show(El.devicesEmpty);
814
+ return;
815
+ }
816
+
817
+ El.devicesList.innerHTML = result.devices.map(d => this.render(d)).join('');
818
+ } catch (error) {
819
+ DOM.hide(El.devicesLoading);
820
+ El.devicesError.textContent = 'Failed to load: ' + error.message;
821
+ DOM.show(El.devicesError);
822
+ }
823
+ },
824
+
825
+ handleError(result) {
826
+ if (result.message?.includes('Not authenticated')) {
827
+ El.devicesEmpty.innerHTML = `
828
+ <i class="bi bi-lock fs-1 mb-2 opacity-50 d-block text-center"></i>
778
829
  <p class="mb-1">Please authenticate first</p>
779
830
  <p class="text-muted small">Go to Authentication tab to connect.</p>`;
780
- DOM.show(El.devicesEmpty);
781
- } else {
782
- El.devicesError.textContent = result.message;
783
- DOM.show(El.devicesError);
784
- }
785
- },
786
-
787
- render(device) {
788
- const online = device.online;
789
- const powerOn = device.powerState === 'on';
790
- const mode = device.operationMode ? Utils.capitalize(device.operationMode) : '-';
791
- const features = device.features?.length
792
- ? `<div class="mt-2">${device.features.map(f => `<span class="badge bg-secondary me-1">${f}</span>`).join('')}</div>`
793
- : '';
794
-
795
- return `
831
+ DOM.show(El.devicesEmpty);
832
+ } else {
833
+ El.devicesError.textContent = result.message;
834
+ DOM.show(El.devicesError);
835
+ }
836
+ },
837
+
838
+ render(device) {
839
+ const online = device.online;
840
+ const powerOn = device.powerState === 'on';
841
+ const mode = device.operationMode ? Utils.capitalize(device.operationMode) : '-';
842
+ const features = device.features?.length
843
+ ? `<div class="mt-2">${device.features.map(f => `<span class="badge bg-secondary me-1">${f}</span>`).join('')}</div>`
844
+ : '';
845
+
846
+ return `
796
847
  <div class="list-group-item">
797
848
  <div class="d-flex justify-content-between align-items-center mb-2">
798
849
  <div class="fw-semibold">${Utils.escapeHtml(device.name)}</div>
@@ -809,9 +860,11 @@ const Devices = {
809
860
  </div>
810
861
  ${features}
811
862
  </div>`;
812
- },
863
+ },
813
864
 
814
- refresh() { this.load(); },
865
+ refresh() {
866
+ this.load();
867
+ },
815
868
  };
816
869
 
817
870
  // ============================================================================
@@ -819,26 +872,26 @@ const Devices = {
819
872
  // ============================================================================
820
873
 
821
874
  const RateLimit = {
822
- async check() {
823
- const display = El.rateLimitDisplay;
824
- display.textContent = 'Checking...';
825
-
826
- try {
827
- const result = await homebridge.request('/api/rate-limit', { mode: AuthMode.current });
828
- if (result.success && result.rateLimit) {
829
- const { limitDay, remainingDay, limitMinute, remainingMinute } = result.rateLimit;
830
- let text = remainingDay !== undefined ? `${remainingDay}/${limitDay} daily` : '';
831
- if (remainingMinute !== undefined) {
832
- text += text ? `, ${remainingMinute}/${limitMinute}/min` : `${remainingMinute}/${limitMinute}/min`;
833
- }
834
- display.textContent = text || 'No rate limit headers';
835
- } else {
836
- display.textContent = result.message || 'No info';
837
- }
838
- } catch (error) {
839
- display.textContent = 'Error: ' + error.message;
875
+ async check() {
876
+ const display = El.rateLimitDisplay;
877
+ display.textContent = 'Checking...';
878
+
879
+ try {
880
+ const result = await homebridge.request('/api/rate-limit', { mode: AuthMode.current });
881
+ if (result.success && result.rateLimit) {
882
+ const { limitDay, remainingDay, limitMinute, remainingMinute } = result.rateLimit;
883
+ let text = remainingDay !== undefined ? `${remainingDay}/${limitDay} daily` : '';
884
+ if (remainingMinute !== undefined) {
885
+ text += text ? `, ${remainingMinute}/${limitMinute}/min` : `${remainingMinute}/${limitMinute}/min`;
840
886
  }
841
- },
887
+ display.textContent = text || 'No rate limit headers';
888
+ } else {
889
+ display.textContent = result.message || 'No info';
890
+ }
891
+ } catch (error) {
892
+ display.textContent = 'Error: ' + error.message;
893
+ }
894
+ },
842
895
  };
843
896
 
844
897
  // ============================================================================
@@ -846,85 +899,89 @@ const RateLimit = {
846
899
  // ============================================================================
847
900
 
848
901
  const MobileAuth = {
849
- show() {
850
- DOM.show($id('mobile-auth-form'));
851
- DOM.hide($id('auth-status-card'));
852
- this.loadCredentials();
853
- },
854
-
855
- hide() {
856
- DOM.hide($id('mobile-auth-form'));
857
- DOM.show($id('auth-status-card'));
858
- DOM.hide($id('mobile-auth-errors'));
859
- DOM.hide($id('mobile-auth-success'));
860
- },
861
-
862
- async loadCredentials() {
863
- try {
864
- const config = await homebridge.getPluginConfig();
865
- if (config?.[0]) {
866
- const email = $id('daikinEmail');
867
- const pass = $id('daikinPassword');
868
- if (email && config[0].daikinEmail) email.value = config[0].daikinEmail;
869
- if (pass && config[0].daikinPassword) pass.value = config[0].daikinPassword;
870
- }
871
- } catch (error) {
872
- console.error('Credential load failed:', error);
902
+ show() {
903
+ DOM.show($id('mobile-auth-form'));
904
+ DOM.hide($id('auth-status-card'));
905
+ this.loadCredentials();
906
+ },
907
+
908
+ hide() {
909
+ DOM.hide($id('mobile-auth-form'));
910
+ DOM.show($id('auth-status-card'));
911
+ DOM.hide($id('mobile-auth-errors'));
912
+ DOM.hide($id('mobile-auth-success'));
913
+ },
914
+
915
+ async loadCredentials() {
916
+ try {
917
+ const config = await homebridge.getPluginConfig();
918
+ if (config?.[0]) {
919
+ const email = $id('daikinEmail');
920
+ const pass = $id('daikinPassword');
921
+ if (email && config[0].daikinEmail) {
922
+ email.value = config[0].daikinEmail;
873
923
  }
874
- },
875
-
876
- async test() {
877
- const email = $id('daikinEmail')?.value.trim();
878
- const password = $id('daikinPassword')?.value;
879
- const errors = $id('mobile-auth-errors');
880
- const success = $id('mobile-auth-success');
881
- const btn = $id('btn-test-mobile-auth');
882
-
883
- DOM.hide(errors);
884
- DOM.hide(success);
885
-
886
- if (!email || !password) {
887
- errors.textContent = 'Please enter email and password';
888
- DOM.show(errors);
889
- return;
924
+ if (pass && config[0].daikinPassword) {
925
+ pass.value = config[0].daikinPassword;
890
926
  }
891
-
892
- UI.setButtonLoading(btn, true, 'Testing...');
893
-
894
- try {
895
- const result = await homebridge.request('/auth/mobile-test', { email, password });
896
-
897
- if (result.success) {
898
- await this.saveCredentials(email, password);
899
- success.innerHTML = `
927
+ }
928
+ } catch (error) {
929
+ console.error('Credential load failed:', error);
930
+ }
931
+ },
932
+
933
+ async test() {
934
+ const email = $id('daikinEmail')?.value.trim();
935
+ const password = $id('daikinPassword')?.value;
936
+ const errors = $id('mobile-auth-errors');
937
+ const success = $id('mobile-auth-success');
938
+ const btn = $id('btn-test-mobile-auth');
939
+
940
+ DOM.hide(errors);
941
+ DOM.hide(success);
942
+
943
+ if (!email || !password) {
944
+ errors.textContent = 'Please enter email and password';
945
+ DOM.show(errors);
946
+ return;
947
+ }
948
+
949
+ UI.setButtonLoading(btn, true, 'Testing...');
950
+
951
+ try {
952
+ const result = await homebridge.request('/auth/mobile-test', { email, password });
953
+
954
+ if (result.success) {
955
+ await this.saveCredentials(email, password);
956
+ success.innerHTML = `
900
957
  <strong>Success!</strong> Found ${result.deviceCount || 0} device(s).<br>
901
958
  Rate limit: ${result.rateLimit?.remainingDay || '?'}/${result.rateLimit?.limitDay || '3000'}/day<br>
902
959
  <small>Restart Homebridge to apply.</small>`;
903
- DOM.show(success);
904
- setTimeout(() => {
905
- this.hide();
906
- Auth.loadStatus();
907
- Devices.load();
908
- }, 2000);
909
- } else {
910
- errors.textContent = result.message || 'Authentication failed';
911
- DOM.show(errors);
912
- }
913
- } catch (error) {
914
- errors.textContent = 'Failed: ' + error.message;
915
- DOM.show(errors);
916
- }
917
-
918
- UI.setButtonLoading(btn, false, null, 'Test & Save Credentials');
919
- },
920
-
921
- async saveCredentials(email, password) {
922
- const config = await homebridge.getPluginConfig();
923
- const platformConfig = config[0] || { platform: 'DaikinCloud' };
924
- Object.assign(platformConfig, { authMode: 'mobile_app', daikinEmail: email, daikinPassword: password });
925
- await homebridge.updatePluginConfig([platformConfig]);
926
- await homebridge.savePluginConfig();
927
- },
960
+ DOM.show(success);
961
+ setTimeout(() => {
962
+ this.hide();
963
+ Auth.loadStatus();
964
+ Devices.load();
965
+ }, 2000);
966
+ } else {
967
+ errors.textContent = result.message || 'Authentication failed';
968
+ DOM.show(errors);
969
+ }
970
+ } catch (error) {
971
+ errors.textContent = 'Failed: ' + error.message;
972
+ DOM.show(errors);
973
+ }
974
+
975
+ UI.setButtonLoading(btn, false, null, 'Test & Save Credentials');
976
+ },
977
+
978
+ async saveCredentials(email, password) {
979
+ const config = await homebridge.getPluginConfig();
980
+ const platformConfig = config[0] || { platform: 'DaikinCloud' };
981
+ Object.assign(platformConfig, { authMode: 'mobile_app', daikinEmail: email, daikinPassword: password });
982
+ await homebridge.updatePluginConfig([platformConfig]);
983
+ await homebridge.savePluginConfig();
984
+ },
928
985
  };
929
986
 
930
987
  // ============================================================================
@@ -932,94 +989,96 @@ const MobileAuth = {
932
989
  // ============================================================================
933
990
 
934
991
  const AuthMode = {
935
- current: 'developer_portal',
936
- previous: 'developer_portal',
937
-
938
- DEFAULTS: {
939
- developer_portal: { updateIntervalInMinutes: 15, forceUpdateDelay: 60, enableWebSocket: false },
940
- mobile_app: { updateIntervalInMinutes: 5, forceUpdateDelay: 10, enableWebSocket: true },
941
- },
942
-
943
- async init() {
944
- try {
945
- const config = await homebridge.getPluginConfig();
946
- if (config?.[0]?.authMode) {
947
- this.current = this.previous = config[0].authMode;
948
- }
949
- } catch (error) {
950
- console.error('AuthMode init failed:', error);
951
- }
952
- this.updateUI();
953
- },
954
-
955
- onChange() {
956
- const select = $id('authMode');
957
- if (select) {
958
- this.previous = this.current;
959
- this.current = select.value;
960
- this.updateUI();
961
- this.updateDefaults();
962
- this.save();
963
- }
964
- },
965
-
966
- updateUI() {
967
- const isMobile = this.current === 'mobile_app';
968
-
969
- $id('authMode').value = this.current;
970
- $id('auth-mode-hint').textContent = isMobile
971
- ? 'Use your Daikin Onecta account (same as mobile app)'
972
- : 'Requires API credentials from the Daikin Developer Portal';
973
-
974
- DOM.toggle($id('btn-authenticate'), !isMobile);
975
- DOM.toggle($id('btn-authenticate-mobile'), isMobile);
976
-
977
- $id('auth-mode-text').textContent = isMobile ? 'Mobile App' : 'Developer Portal';
978
- $id('rate-limit-display').textContent = isMobile ? '3000 requests/day' : '200 requests/day';
979
- $id('rate-limit-info').textContent = `The Daikin API limits you to ${isMobile ? '3000' : '200'} requests per day.`;
980
-
981
- DOM.toggle($id('websocket-setting-row'), isMobile);
982
-
983
- this.updateHints();
984
- },
985
-
986
- updateHints() {
987
- const isMobile = this.current === 'mobile_app';
988
- const interval = $id('updateIntervalInMinutes');
989
- const delay = $id('forceUpdateDelay');
990
-
991
- if (interval) {
992
- interval.placeholder = isMobile ? '5 (recommended)' : '15 (recommended)';
993
- interval.title = isMobile ? '1-5 min (3000 calls/day)' : '15+ min (200 calls/day)';
994
- }
995
- if (delay) {
996
- delay.placeholder = isMobile ? '10 (recommended)' : '60 (recommended)';
997
- delay.title = isMobile ? '10s recommended' : '60s recommended';
998
- }
999
- },
1000
-
1001
- updateDefaults() {
1002
- if (this.current === this.previous) return;
1003
-
1004
- const defaults = this.DEFAULTS[this.current];
1005
- $id('updateIntervalInMinutes').value = defaults.updateIntervalInMinutes;
1006
- $id('forceUpdateDelay').value = defaults.forceUpdateDelay;
1007
- $id('enableWebSocket').checked = defaults.enableWebSocket;
1008
-
1009
- Settings.autoSave();
1010
- },
1011
-
1012
- async save() {
1013
- try {
1014
- const config = await homebridge.getPluginConfig();
1015
- const platformConfig = config[0] || { platform: 'DaikinCloud' };
1016
- platformConfig.authMode = this.current;
1017
- await homebridge.updatePluginConfig([platformConfig]);
1018
- await homebridge.savePluginConfig();
1019
- } catch (error) {
1020
- console.error('AuthMode save failed:', error);
1021
- }
1022
- },
992
+ current: 'developer_portal',
993
+ previous: 'developer_portal',
994
+
995
+ DEFAULTS: {
996
+ developer_portal: { updateIntervalInMinutes: 15, forceUpdateDelay: 60, enableWebSocket: false },
997
+ mobile_app: { updateIntervalInMinutes: 5, forceUpdateDelay: 10, enableWebSocket: true },
998
+ },
999
+
1000
+ async init() {
1001
+ try {
1002
+ const config = await homebridge.getPluginConfig();
1003
+ if (config?.[0]?.authMode) {
1004
+ this.current = this.previous = config[0].authMode;
1005
+ }
1006
+ } catch (error) {
1007
+ console.error('AuthMode init failed:', error);
1008
+ }
1009
+ this.updateUI();
1010
+ },
1011
+
1012
+ onChange() {
1013
+ const select = $id('authMode');
1014
+ if (select) {
1015
+ this.previous = this.current;
1016
+ this.current = select.value;
1017
+ this.updateUI();
1018
+ this.updateDefaults();
1019
+ this.save();
1020
+ }
1021
+ },
1022
+
1023
+ updateUI() {
1024
+ const isMobile = this.current === 'mobile_app';
1025
+
1026
+ $id('authMode').value = this.current;
1027
+ $id('auth-mode-hint').textContent = isMobile
1028
+ ? 'Use your Daikin Onecta account (same as mobile app)'
1029
+ : 'Requires API credentials from the Daikin Developer Portal';
1030
+
1031
+ DOM.toggle($id('btn-authenticate'), !isMobile);
1032
+ DOM.toggle($id('btn-authenticate-mobile'), isMobile);
1033
+
1034
+ $id('auth-mode-text').textContent = isMobile ? 'Mobile App' : 'Developer Portal';
1035
+ $id('rate-limit-display').textContent = isMobile ? '3000 requests/day' : '200 requests/day';
1036
+ $id('rate-limit-info').textContent = `The Daikin API limits you to ${isMobile ? '3000' : '200'} requests per day.`;
1037
+
1038
+ DOM.toggle($id('websocket-setting-row'), isMobile);
1039
+
1040
+ this.updateHints();
1041
+ },
1042
+
1043
+ updateHints() {
1044
+ const isMobile = this.current === 'mobile_app';
1045
+ const interval = $id('updateIntervalInMinutes');
1046
+ const delay = $id('forceUpdateDelay');
1047
+
1048
+ if (interval) {
1049
+ interval.placeholder = isMobile ? '5 (recommended)' : '15 (recommended)';
1050
+ interval.title = isMobile ? '1-5 min (3000 calls/day)' : '15+ min (200 calls/day)';
1051
+ }
1052
+ if (delay) {
1053
+ delay.placeholder = isMobile ? '10 (recommended)' : '60 (recommended)';
1054
+ delay.title = isMobile ? '10s recommended' : '60s recommended';
1055
+ }
1056
+ },
1057
+
1058
+ updateDefaults() {
1059
+ if (this.current === this.previous) {
1060
+ return;
1061
+ }
1062
+
1063
+ const defaults = this.DEFAULTS[this.current];
1064
+ $id('updateIntervalInMinutes').value = defaults.updateIntervalInMinutes;
1065
+ $id('forceUpdateDelay').value = defaults.forceUpdateDelay;
1066
+ $id('enableWebSocket').checked = defaults.enableWebSocket;
1067
+
1068
+ Settings.autoSave();
1069
+ },
1070
+
1071
+ async save() {
1072
+ try {
1073
+ const config = await homebridge.getPluginConfig();
1074
+ const platformConfig = config[0] || { platform: 'DaikinCloud' };
1075
+ platformConfig.authMode = this.current;
1076
+ await homebridge.updatePluginConfig([platformConfig]);
1077
+ await homebridge.savePluginConfig();
1078
+ } catch (error) {
1079
+ console.error('AuthMode save failed:', error);
1080
+ }
1081
+ },
1023
1082
  };
1024
1083
 
1025
1084
  // ============================================================================
@@ -1047,42 +1106,42 @@ const onAuthModeChange = () => AuthMode.onChange();
1047
1106
  // ============================================================================
1048
1107
 
1049
1108
  function cacheElements() {
1050
- El = {
1051
- statusBadge: $id('status-badge'),
1052
- statusIndicator: $id('status-indicator'),
1053
- statusText: $id('status-text'),
1054
- tokenExpiresLabel: $id('token-expires-label'),
1055
- tokenExpires: $id('token-expires'),
1056
- authStatus: $id('auth-status'),
1057
- authTokenExpires: $id('auth-token-expires'),
1058
- expiresRow: $id('expires-row'),
1059
- btnAuthenticate: $id('btn-authenticate'),
1060
- btnRevoke: $id('btn-revoke'),
1061
- btnTest: $id('btn-test'),
1062
- testResult: $id('test-result'),
1063
- wizard: $id('wizard'),
1064
- authStatusCard: $id('auth-status-card'),
1065
- validationErrors: $id('validation-errors'),
1066
- authUrlContainer: $id('auth-url-container'),
1067
- authUrlDisplay: $id('auth-url'),
1068
- callbackServerStatus: $id('callback-server-status'),
1069
- successMessage: $id('success-message'),
1070
- devicesLoading: $id('devices-loading'),
1071
- devicesList: $id('devices-list'),
1072
- devicesEmpty: $id('devices-empty'),
1073
- devicesError: $id('devices-error'),
1074
- settingsStatus: $id('settings-status'),
1075
- rateLimitDisplay: $id('rate-limit-display'),
1076
- loading: $id('loading'),
1077
- globalError: $id('global-error'),
1078
- };
1109
+ El = {
1110
+ statusBadge: $id('status-badge'),
1111
+ statusIndicator: $id('status-indicator'),
1112
+ statusText: $id('status-text'),
1113
+ tokenExpiresLabel: $id('token-expires-label'),
1114
+ tokenExpires: $id('token-expires'),
1115
+ authStatus: $id('auth-status'),
1116
+ authTokenExpires: $id('auth-token-expires'),
1117
+ expiresRow: $id('expires-row'),
1118
+ btnAuthenticate: $id('btn-authenticate'),
1119
+ btnRevoke: $id('btn-revoke'),
1120
+ btnTest: $id('btn-test'),
1121
+ testResult: $id('test-result'),
1122
+ wizard: $id('wizard'),
1123
+ authStatusCard: $id('auth-status-card'),
1124
+ validationErrors: $id('validation-errors'),
1125
+ authUrlContainer: $id('auth-url-container'),
1126
+ authUrlDisplay: $id('auth-url'),
1127
+ callbackServerStatus: $id('callback-server-status'),
1128
+ successMessage: $id('success-message'),
1129
+ devicesLoading: $id('devices-loading'),
1130
+ devicesList: $id('devices-list'),
1131
+ devicesEmpty: $id('devices-empty'),
1132
+ devicesError: $id('devices-error'),
1133
+ settingsStatus: $id('settings-status'),
1134
+ rateLimitDisplay: $id('rate-limit-display'),
1135
+ loading: $id('loading'),
1136
+ globalError: $id('global-error'),
1137
+ };
1079
1138
  }
1080
1139
 
1081
1140
  document.addEventListener('DOMContentLoaded', async () => {
1082
- cacheElements();
1083
- Auth.loadStatus();
1084
- Config.load();
1085
- await AuthMode.init();
1086
- Settings.load();
1087
- Devices.load();
1141
+ cacheElements();
1142
+ Auth.loadStatus();
1143
+ Config.load();
1144
+ await AuthMode.init();
1145
+ Settings.load();
1146
+ Devices.load();
1088
1147
  });