From d7a332ec60c8e2d3e7560f989379acdb10481261 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 8 May 2026 11:23:08 +0000 Subject: [PATCH] Add native local edge service integrations --- .../test.emoncms_history.node.ts | 71 ++++ test/emonitor/test.emonitor.node.ts | 75 +++++ test/emulated_hue/test.emulated_hue.node.ts | 72 +++++ test/emulated_kasa/test.emulated_kasa.node.ts | 74 +++++ test/emulated_roku/test.emulated_roku.node.ts | 72 +++++ .../test.energenie_power_sockets.node.ts | 68 ++++ test/enigma2/test.enigma2.node.ts | 85 +++++ test/enocean/test.enocean.node.ts | 73 +++++ test/enphase_envoy/test.enphase_envoy.node.ts | 99 ++++++ test/envisalink/test.envisalink.node.ts | 74 +++++ test/ephember/test.ephember.node.ts | 73 +++++ test/epson/test.epson.node.ts | 73 +++++ test/eq3btsmart/test.eq3btsmart.node.ts | 79 +++++ test/escea/test.escea.node.ts | 71 ++++ test/eufy/test.eufy.node.ts | 72 +++++ test/eufylife_ble/test.eufylife_ble.node.ts | 72 +++++ .../test.eurotronic_cometblue.node.ts | 72 +++++ test/everlights/test.everlights.node.ts | 72 +++++ .../test.evil_genius_labs.node.ts | 80 +++++ test/fail2ban/test.fail2ban.node.ts | 76 +++++ test/familyhub/test.familyhub.node.ts | 81 +++++ test/fibaro/test.fibaro.node.ts | 80 +++++ test/file/test.file.node.ts | 66 ++++ test/filesize/sample.txt | 1 + test/filesize/test.filesize.node.ts | 69 ++++ test/fing/test.fing.node.ts | 72 +++++ test/firefly_iii/test.firefly_iii.node.ts | 70 ++++ test/firmata/test.firmata.node.ts | 84 +++++ test/fivem/test.fivem.node.ts | 80 +++++ test/fjaraskupan/test.fjaraskupan.node.ts | 85 +++++ test/flexit/test.flexit.node.ts | 97 ++++++ ts/index.ts | 60 ++++ .../.generated-by-smarthome-exchange | 1 - .../emoncms_history.classes.client.ts | 19 ++ .../emoncms_history.classes.configflow.ts | 9 + .../emoncms_history.classes.integration.ts | 41 ++- .../emoncms_history.discovery.ts | 4 + .../emoncms_history/emoncms_history.mapper.ts | 122 +++++++ .../emoncms_history/emoncms_history.types.ts | 95 +++++- ts/integrations/emoncms_history/index.ts | 4 + .../emonitor/.generated-by-smarthome-exchange | 1 - .../emonitor/emonitor.classes.client.ts | 19 ++ .../emonitor/emonitor.classes.configflow.ts | 9 + .../emonitor/emonitor.classes.integration.ts | 41 ++- .../emonitor/emonitor.discovery.ts | 4 + ts/integrations/emonitor/emonitor.mapper.ts | 134 ++++++++ ts/integrations/emonitor/emonitor.types.ts | 91 +++++- ts/integrations/emonitor/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../emulated_hue.classes.client.ts | 19 ++ .../emulated_hue.classes.configflow.ts | 9 + .../emulated_hue.classes.integration.ts | 44 ++- .../emulated_hue/emulated_hue.discovery.ts | 4 + .../emulated_hue/emulated_hue.mapper.ts | 206 ++++++++++++ .../emulated_hue/emulated_hue.types.ts | 150 ++++++++- ts/integrations/emulated_hue/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../emulated_kasa.classes.client.ts | 19 ++ .../emulated_kasa.classes.configflow.ts | 9 + .../emulated_kasa.classes.integration.ts | 41 ++- .../emulated_kasa/emulated_kasa.discovery.ts | 4 + .../emulated_kasa/emulated_kasa.mapper.ts | 185 +++++++++++ .../emulated_kasa/emulated_kasa.types.ts | 82 ++++- ts/integrations/emulated_kasa/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../emulated_roku.classes.client.ts | 19 ++ .../emulated_roku.classes.configflow.ts | 9 + .../emulated_roku.classes.integration.ts | 40 ++- .../emulated_roku/emulated_roku.discovery.ts | 4 + .../emulated_roku/emulated_roku.mapper.ts | 158 +++++++++ .../emulated_roku/emulated_roku.types.ts | 82 ++++- ts/integrations/emulated_roku/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../energenie_power_sockets.classes.client.ts | 19 ++ ...rgenie_power_sockets.classes.configflow.ts | 9 + ...genie_power_sockets.classes.integration.ts | 41 ++- .../energenie_power_sockets.discovery.ts | 4 + .../energenie_power_sockets.mapper.ts | 162 ++++++++++ .../energenie_power_sockets.types.ts | 90 +++++- .../energenie_power_sockets/index.ts | 4 + .../enigma2/.generated-by-smarthome-exchange | 1 - .../enigma2/enigma2.classes.client.ts | 19 ++ .../enigma2/enigma2.classes.configflow.ts | 9 + .../enigma2/enigma2.classes.integration.ts | 41 ++- ts/integrations/enigma2/enigma2.discovery.ts | 4 + ts/integrations/enigma2/enigma2.mapper.ts | 138 ++++++++ ts/integrations/enigma2/enigma2.types.ts | 112 ++++++- ts/integrations/enigma2/index.ts | 4 + .../enocean/.generated-by-smarthome-exchange | 1 - .../enocean/enocean.classes.client.ts | 19 ++ .../enocean/enocean.classes.configflow.ts | 9 + .../enocean/enocean.classes.integration.ts | 41 ++- ts/integrations/enocean/enocean.discovery.ts | 4 + ts/integrations/enocean/enocean.mapper.ts | 232 +++++++++++++ ts/integrations/enocean/enocean.types.ts | 113 ++++++- ts/integrations/enocean/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../enphase_envoy.classes.client.ts | 23 ++ .../enphase_envoy.classes.configflow.ts | 9 + .../enphase_envoy.classes.integration.ts | 44 ++- .../enphase_envoy/enphase_envoy.discovery.ts | 4 + .../enphase_envoy/enphase_envoy.mapper.ts | 283 ++++++++++++++++ .../enphase_envoy/enphase_envoy.types.ts | 115 ++++++- ts/integrations/enphase_envoy/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../envisalink/envisalink.classes.client.ts | 9 + .../envisalink.classes.configflow.ts | 9 + .../envisalink.classes.integration.ts | 33 +- .../envisalink/envisalink.discovery.ts | 4 + .../envisalink/envisalink.mapper.ts | 26 ++ .../envisalink/envisalink.types.ts | 124 ++++++- ts/integrations/envisalink/index.ts | 4 + .../ephember/.generated-by-smarthome-exchange | 1 - .../ephember/ephember.classes.client.ts | 9 + .../ephember/ephember.classes.configflow.ts | 9 + .../ephember/ephember.classes.integration.ts | 36 +-- .../ephember/ephember.discovery.ts | 4 + ts/integrations/ephember/ephember.mapper.ts | 26 ++ ts/integrations/ephember/ephember.types.ts | 95 +++++- ts/integrations/ephember/index.ts | 4 + .../epson/.generated-by-smarthome-exchange | 1 - ts/integrations/epson/epson.classes.client.ts | 9 + .../epson/epson.classes.configflow.ts | 9 + .../epson/epson.classes.integration.ts | 35 +- ts/integrations/epson/epson.discovery.ts | 4 + ts/integrations/epson/epson.mapper.ts | 26 ++ ts/integrations/epson/epson.types.ts | 111 ++++++- ts/integrations/epson/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../eq3btsmart/eq3btsmart.classes.client.ts | 9 + .../eq3btsmart.classes.configflow.ts | 9 + .../eq3btsmart.classes.integration.ts | 39 +-- .../eq3btsmart/eq3btsmart.discovery.ts | 4 + .../eq3btsmart/eq3btsmart.mapper.ts | 26 ++ .../eq3btsmart/eq3btsmart.types.ts | 141 +++++++- ts/integrations/eq3btsmart/index.ts | 4 + .../escea/.generated-by-smarthome-exchange | 1 - ts/integrations/escea/escea.classes.client.ts | 9 + .../escea/escea.classes.configflow.ts | 9 + .../escea/escea.classes.integration.ts | 35 +- ts/integrations/escea/escea.discovery.ts | 4 + ts/integrations/escea/escea.mapper.ts | 26 ++ ts/integrations/escea/escea.types.ts | 103 +++++- ts/integrations/escea/index.ts | 4 + .../eufy/.generated-by-smarthome-exchange | 1 - ts/integrations/eufy/eufy.classes.client.ts | 9 + .../eufy/eufy.classes.configflow.ts | 9 + .../eufy/eufy.classes.integration.ts | 33 +- ts/integrations/eufy/eufy.discovery.ts | 4 + ts/integrations/eufy/eufy.mapper.ts | 26 ++ ts/integrations/eufy/eufy.types.ts | 122 ++++++- ts/integrations/eufy/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../eufylife_ble.classes.client.ts | 9 + .../eufylife_ble.classes.configflow.ts | 9 + .../eufylife_ble.classes.integration.ts | 37 +-- .../eufylife_ble/eufylife_ble.discovery.ts | 4 + .../eufylife_ble/eufylife_ble.mapper.ts | 26 ++ .../eufylife_ble/eufylife_ble.types.ts | 93 +++++- ts/integrations/eufylife_ble/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../eurotronic_cometblue.classes.client.ts | 9 + ...eurotronic_cometblue.classes.configflow.ts | 9 + ...urotronic_cometblue.classes.integration.ts | 38 +-- .../eurotronic_cometblue.discovery.ts | 4 + .../eurotronic_cometblue.mapper.ts | 26 ++ .../eurotronic_cometblue.types.ts | 110 ++++++- ts/integrations/eurotronic_cometblue/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../everlights/everlights.classes.client.ts | 9 + .../everlights.classes.configflow.ts | 9 + .../everlights.classes.integration.ts | 33 +- .../everlights/everlights.discovery.ts | 4 + .../everlights/everlights.mapper.ts | 26 ++ .../everlights/everlights.types.ts | 88 ++++- ts/integrations/everlights/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../evil_genius_labs.classes.client.ts | 9 + .../evil_genius_labs.classes.configflow.ts | 9 + .../evil_genius_labs.classes.integration.ts | 33 +- .../evil_genius_labs.discovery.ts | 4 + .../evil_genius_labs.mapper.ts | 26 ++ .../evil_genius_labs.types.ts | 93 +++++- ts/integrations/evil_genius_labs/index.ts | 4 + .../fail2ban/.generated-by-smarthome-exchange | 1 - .../fail2ban/fail2ban.classes.client.ts | 9 + .../fail2ban/fail2ban.classes.configflow.ts | 9 + .../fail2ban/fail2ban.classes.integration.ts | 31 +- .../fail2ban/fail2ban.discovery.ts | 4 + ts/integrations/fail2ban/fail2ban.mapper.ts | 26 ++ ts/integrations/fail2ban/fail2ban.types.ts | 74 ++++- ts/integrations/fail2ban/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../familyhub/familyhub.classes.client.ts | 9 + .../familyhub/familyhub.classes.configflow.ts | 9 + .../familyhub.classes.integration.ts | 33 +- .../familyhub/familyhub.discovery.ts | 4 + ts/integrations/familyhub/familyhub.mapper.ts | 26 ++ ts/integrations/familyhub/familyhub.types.ts | 88 ++++- ts/integrations/familyhub/index.ts | 4 + .../fibaro/.generated-by-smarthome-exchange | 1 - .../fibaro/fibaro.classes.client.ts | 23 ++ .../fibaro/fibaro.classes.configflow.ts | 9 + .../fibaro/fibaro.classes.integration.ts | 41 ++- ts/integrations/fibaro/fibaro.discovery.ts | 4 + ts/integrations/fibaro/fibaro.mapper.ts | 305 ++++++++++++++++++ ts/integrations/fibaro/fibaro.types.ts | 99 +++++- ts/integrations/fibaro/index.ts | 4 + .../file/.generated-by-smarthome-exchange | 1 - ts/integrations/file/file.classes.client.ts | 56 ++++ .../file/file.classes.configflow.ts | 9 + .../file/file.classes.integration.ts | 40 ++- ts/integrations/file/file.discovery.ts | 4 + ts/integrations/file/file.mapper.ts | 89 +++++ ts/integrations/file/file.types.ts | 87 ++++- ts/integrations/file/index.ts | 4 + .../filesize/.generated-by-smarthome-exchange | 1 - .../filesize/filesize.classes.client.ts | 63 ++++ .../filesize/filesize.classes.configflow.ts | 9 + .../filesize/filesize.classes.integration.ts | 38 +-- .../filesize/filesize.discovery.ts | 4 + ts/integrations/filesize/filesize.mapper.ts | 146 +++++++++ ts/integrations/filesize/filesize.types.ts | 78 ++++- ts/integrations/filesize/index.ts | 4 + .../fing/.generated-by-smarthome-exchange | 1 - ts/integrations/fing/fing.classes.client.ts | 9 + .../fing/fing.classes.configflow.ts | 9 + .../fing/fing.classes.integration.ts | 36 +-- ts/integrations/fing/fing.discovery.ts | 4 + ts/integrations/fing/fing.mapper.ts | 26 ++ ts/integrations/fing/fing.types.ts | 87 ++++- ts/integrations/fing/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../firefly_iii/firefly_iii.classes.client.ts | 9 + .../firefly_iii.classes.configflow.ts | 9 + .../firefly_iii.classes.integration.ts | 36 +-- .../firefly_iii/firefly_iii.discovery.ts | 4 + .../firefly_iii/firefly_iii.mapper.ts | 26 ++ .../firefly_iii/firefly_iii.types.ts | 86 ++++- ts/integrations/firefly_iii/index.ts | 4 + .../firmata/.generated-by-smarthome-exchange | 1 - .../firmata/firmata.classes.client.ts | 9 + .../firmata/firmata.classes.configflow.ts | 9 + .../firmata/firmata.classes.integration.ts | 35 +- ts/integrations/firmata/firmata.discovery.ts | 4 + ts/integrations/firmata/firmata.mapper.ts | 26 ++ ts/integrations/firmata/firmata.types.ts | 129 +++++++- ts/integrations/firmata/index.ts | 4 + .../fivem/.generated-by-smarthome-exchange | 1 - ts/integrations/fivem/fivem.classes.client.ts | 9 + .../fivem/fivem.classes.configflow.ts | 9 + .../fivem/fivem.classes.integration.ts | 35 +- ts/integrations/fivem/fivem.discovery.ts | 4 + ts/integrations/fivem/fivem.mapper.ts | 26 ++ ts/integrations/fivem/fivem.types.ts | 98 +++++- ts/integrations/fivem/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../fjaraskupan/fjaraskupan.classes.client.ts | 9 + .../fjaraskupan.classes.configflow.ts | 9 + .../fjaraskupan.classes.integration.ts | 37 +-- .../fjaraskupan/fjaraskupan.discovery.ts | 4 + .../fjaraskupan/fjaraskupan.mapper.ts | 26 ++ .../fjaraskupan/fjaraskupan.types.ts | 122 ++++++- ts/integrations/fjaraskupan/index.ts | 4 + .../flexit/.generated-by-smarthome-exchange | 1 - .../flexit/flexit.classes.client.ts | 9 + .../flexit/flexit.classes.configflow.ts | 9 + .../flexit/flexit.classes.integration.ts | 33 +- ts/integrations/flexit/flexit.discovery.ts | 4 + ts/integrations/flexit/flexit.mapper.ts | 26 ++ ts/integrations/flexit/flexit.types.ts | 96 +++++- ts/integrations/flexit/index.ts | 4 + ts/integrations/generated/index.ts | 92 ++---- ts/integrations/index.ts | 30 ++ 274 files changed, 9451 insertions(+), 841 deletions(-) create mode 100644 test/emoncms_history/test.emoncms_history.node.ts create mode 100644 test/emonitor/test.emonitor.node.ts create mode 100644 test/emulated_hue/test.emulated_hue.node.ts create mode 100644 test/emulated_kasa/test.emulated_kasa.node.ts create mode 100644 test/emulated_roku/test.emulated_roku.node.ts create mode 100644 test/energenie_power_sockets/test.energenie_power_sockets.node.ts create mode 100644 test/enigma2/test.enigma2.node.ts create mode 100644 test/enocean/test.enocean.node.ts create mode 100644 test/enphase_envoy/test.enphase_envoy.node.ts create mode 100644 test/envisalink/test.envisalink.node.ts create mode 100644 test/ephember/test.ephember.node.ts create mode 100644 test/epson/test.epson.node.ts create mode 100644 test/eq3btsmart/test.eq3btsmart.node.ts create mode 100644 test/escea/test.escea.node.ts create mode 100644 test/eufy/test.eufy.node.ts create mode 100644 test/eufylife_ble/test.eufylife_ble.node.ts create mode 100644 test/eurotronic_cometblue/test.eurotronic_cometblue.node.ts create mode 100644 test/everlights/test.everlights.node.ts create mode 100644 test/evil_genius_labs/test.evil_genius_labs.node.ts create mode 100644 test/fail2ban/test.fail2ban.node.ts create mode 100644 test/familyhub/test.familyhub.node.ts create mode 100644 test/fibaro/test.fibaro.node.ts create mode 100644 test/file/test.file.node.ts create mode 100644 test/filesize/sample.txt create mode 100644 test/filesize/test.filesize.node.ts create mode 100644 test/fing/test.fing.node.ts create mode 100644 test/firefly_iii/test.firefly_iii.node.ts create mode 100644 test/firmata/test.firmata.node.ts create mode 100644 test/fivem/test.fivem.node.ts create mode 100644 test/fjaraskupan/test.fjaraskupan.node.ts create mode 100644 test/flexit/test.flexit.node.ts delete mode 100644 ts/integrations/emoncms_history/.generated-by-smarthome-exchange create mode 100644 ts/integrations/emoncms_history/emoncms_history.classes.client.ts create mode 100644 ts/integrations/emoncms_history/emoncms_history.classes.configflow.ts create mode 100644 ts/integrations/emoncms_history/emoncms_history.discovery.ts create mode 100644 ts/integrations/emoncms_history/emoncms_history.mapper.ts delete mode 100644 ts/integrations/emonitor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/emonitor/emonitor.classes.client.ts create mode 100644 ts/integrations/emonitor/emonitor.classes.configflow.ts create mode 100644 ts/integrations/emonitor/emonitor.discovery.ts create mode 100644 ts/integrations/emonitor/emonitor.mapper.ts delete mode 100644 ts/integrations/emulated_hue/.generated-by-smarthome-exchange create mode 100644 ts/integrations/emulated_hue/emulated_hue.classes.client.ts create mode 100644 ts/integrations/emulated_hue/emulated_hue.classes.configflow.ts create mode 100644 ts/integrations/emulated_hue/emulated_hue.discovery.ts create mode 100644 ts/integrations/emulated_hue/emulated_hue.mapper.ts delete mode 100644 ts/integrations/emulated_kasa/.generated-by-smarthome-exchange create mode 100644 ts/integrations/emulated_kasa/emulated_kasa.classes.client.ts create mode 100644 ts/integrations/emulated_kasa/emulated_kasa.classes.configflow.ts create mode 100644 ts/integrations/emulated_kasa/emulated_kasa.discovery.ts create mode 100644 ts/integrations/emulated_kasa/emulated_kasa.mapper.ts delete mode 100644 ts/integrations/emulated_roku/.generated-by-smarthome-exchange create mode 100644 ts/integrations/emulated_roku/emulated_roku.classes.client.ts create mode 100644 ts/integrations/emulated_roku/emulated_roku.classes.configflow.ts create mode 100644 ts/integrations/emulated_roku/emulated_roku.discovery.ts create mode 100644 ts/integrations/emulated_roku/emulated_roku.mapper.ts delete mode 100644 ts/integrations/energenie_power_sockets/.generated-by-smarthome-exchange create mode 100644 ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.client.ts create mode 100644 ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.configflow.ts create mode 100644 ts/integrations/energenie_power_sockets/energenie_power_sockets.discovery.ts create mode 100644 ts/integrations/energenie_power_sockets/energenie_power_sockets.mapper.ts delete mode 100644 ts/integrations/enigma2/.generated-by-smarthome-exchange create mode 100644 ts/integrations/enigma2/enigma2.classes.client.ts create mode 100644 ts/integrations/enigma2/enigma2.classes.configflow.ts create mode 100644 ts/integrations/enigma2/enigma2.discovery.ts create mode 100644 ts/integrations/enigma2/enigma2.mapper.ts delete mode 100644 ts/integrations/enocean/.generated-by-smarthome-exchange create mode 100644 ts/integrations/enocean/enocean.classes.client.ts create mode 100644 ts/integrations/enocean/enocean.classes.configflow.ts create mode 100644 ts/integrations/enocean/enocean.discovery.ts create mode 100644 ts/integrations/enocean/enocean.mapper.ts delete mode 100644 ts/integrations/enphase_envoy/.generated-by-smarthome-exchange create mode 100644 ts/integrations/enphase_envoy/enphase_envoy.classes.client.ts create mode 100644 ts/integrations/enphase_envoy/enphase_envoy.classes.configflow.ts create mode 100644 ts/integrations/enphase_envoy/enphase_envoy.discovery.ts create mode 100644 ts/integrations/enphase_envoy/enphase_envoy.mapper.ts delete mode 100644 ts/integrations/envisalink/.generated-by-smarthome-exchange create mode 100644 ts/integrations/envisalink/envisalink.classes.client.ts create mode 100644 ts/integrations/envisalink/envisalink.classes.configflow.ts create mode 100644 ts/integrations/envisalink/envisalink.discovery.ts create mode 100644 ts/integrations/envisalink/envisalink.mapper.ts delete mode 100644 ts/integrations/ephember/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ephember/ephember.classes.client.ts create mode 100644 ts/integrations/ephember/ephember.classes.configflow.ts create mode 100644 ts/integrations/ephember/ephember.discovery.ts create mode 100644 ts/integrations/ephember/ephember.mapper.ts delete mode 100644 ts/integrations/epson/.generated-by-smarthome-exchange create mode 100644 ts/integrations/epson/epson.classes.client.ts create mode 100644 ts/integrations/epson/epson.classes.configflow.ts create mode 100644 ts/integrations/epson/epson.discovery.ts create mode 100644 ts/integrations/epson/epson.mapper.ts delete mode 100644 ts/integrations/eq3btsmart/.generated-by-smarthome-exchange create mode 100644 ts/integrations/eq3btsmart/eq3btsmart.classes.client.ts create mode 100644 ts/integrations/eq3btsmart/eq3btsmart.classes.configflow.ts create mode 100644 ts/integrations/eq3btsmart/eq3btsmart.discovery.ts create mode 100644 ts/integrations/eq3btsmart/eq3btsmart.mapper.ts delete mode 100644 ts/integrations/escea/.generated-by-smarthome-exchange create mode 100644 ts/integrations/escea/escea.classes.client.ts create mode 100644 ts/integrations/escea/escea.classes.configflow.ts create mode 100644 ts/integrations/escea/escea.discovery.ts create mode 100644 ts/integrations/escea/escea.mapper.ts delete mode 100644 ts/integrations/eufy/.generated-by-smarthome-exchange create mode 100644 ts/integrations/eufy/eufy.classes.client.ts create mode 100644 ts/integrations/eufy/eufy.classes.configflow.ts create mode 100644 ts/integrations/eufy/eufy.discovery.ts create mode 100644 ts/integrations/eufy/eufy.mapper.ts delete mode 100644 ts/integrations/eufylife_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/eufylife_ble/eufylife_ble.classes.client.ts create mode 100644 ts/integrations/eufylife_ble/eufylife_ble.classes.configflow.ts create mode 100644 ts/integrations/eufylife_ble/eufylife_ble.discovery.ts create mode 100644 ts/integrations/eufylife_ble/eufylife_ble.mapper.ts delete mode 100644 ts/integrations/eurotronic_cometblue/.generated-by-smarthome-exchange create mode 100644 ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.client.ts create mode 100644 ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.configflow.ts create mode 100644 ts/integrations/eurotronic_cometblue/eurotronic_cometblue.discovery.ts create mode 100644 ts/integrations/eurotronic_cometblue/eurotronic_cometblue.mapper.ts delete mode 100644 ts/integrations/everlights/.generated-by-smarthome-exchange create mode 100644 ts/integrations/everlights/everlights.classes.client.ts create mode 100644 ts/integrations/everlights/everlights.classes.configflow.ts create mode 100644 ts/integrations/everlights/everlights.discovery.ts create mode 100644 ts/integrations/everlights/everlights.mapper.ts delete mode 100644 ts/integrations/evil_genius_labs/.generated-by-smarthome-exchange create mode 100644 ts/integrations/evil_genius_labs/evil_genius_labs.classes.client.ts create mode 100644 ts/integrations/evil_genius_labs/evil_genius_labs.classes.configflow.ts create mode 100644 ts/integrations/evil_genius_labs/evil_genius_labs.discovery.ts create mode 100644 ts/integrations/evil_genius_labs/evil_genius_labs.mapper.ts delete mode 100644 ts/integrations/fail2ban/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fail2ban/fail2ban.classes.client.ts create mode 100644 ts/integrations/fail2ban/fail2ban.classes.configflow.ts create mode 100644 ts/integrations/fail2ban/fail2ban.discovery.ts create mode 100644 ts/integrations/fail2ban/fail2ban.mapper.ts delete mode 100644 ts/integrations/familyhub/.generated-by-smarthome-exchange create mode 100644 ts/integrations/familyhub/familyhub.classes.client.ts create mode 100644 ts/integrations/familyhub/familyhub.classes.configflow.ts create mode 100644 ts/integrations/familyhub/familyhub.discovery.ts create mode 100644 ts/integrations/familyhub/familyhub.mapper.ts delete mode 100644 ts/integrations/fibaro/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fibaro/fibaro.classes.client.ts create mode 100644 ts/integrations/fibaro/fibaro.classes.configflow.ts create mode 100644 ts/integrations/fibaro/fibaro.discovery.ts create mode 100644 ts/integrations/fibaro/fibaro.mapper.ts delete mode 100644 ts/integrations/file/.generated-by-smarthome-exchange create mode 100644 ts/integrations/file/file.classes.client.ts create mode 100644 ts/integrations/file/file.classes.configflow.ts create mode 100644 ts/integrations/file/file.discovery.ts create mode 100644 ts/integrations/file/file.mapper.ts delete mode 100644 ts/integrations/filesize/.generated-by-smarthome-exchange create mode 100644 ts/integrations/filesize/filesize.classes.client.ts create mode 100644 ts/integrations/filesize/filesize.classes.configflow.ts create mode 100644 ts/integrations/filesize/filesize.discovery.ts create mode 100644 ts/integrations/filesize/filesize.mapper.ts delete mode 100644 ts/integrations/fing/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fing/fing.classes.client.ts create mode 100644 ts/integrations/fing/fing.classes.configflow.ts create mode 100644 ts/integrations/fing/fing.discovery.ts create mode 100644 ts/integrations/fing/fing.mapper.ts delete mode 100644 ts/integrations/firefly_iii/.generated-by-smarthome-exchange create mode 100644 ts/integrations/firefly_iii/firefly_iii.classes.client.ts create mode 100644 ts/integrations/firefly_iii/firefly_iii.classes.configflow.ts create mode 100644 ts/integrations/firefly_iii/firefly_iii.discovery.ts create mode 100644 ts/integrations/firefly_iii/firefly_iii.mapper.ts delete mode 100644 ts/integrations/firmata/.generated-by-smarthome-exchange create mode 100644 ts/integrations/firmata/firmata.classes.client.ts create mode 100644 ts/integrations/firmata/firmata.classes.configflow.ts create mode 100644 ts/integrations/firmata/firmata.discovery.ts create mode 100644 ts/integrations/firmata/firmata.mapper.ts delete mode 100644 ts/integrations/fivem/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fivem/fivem.classes.client.ts create mode 100644 ts/integrations/fivem/fivem.classes.configflow.ts create mode 100644 ts/integrations/fivem/fivem.discovery.ts create mode 100644 ts/integrations/fivem/fivem.mapper.ts delete mode 100644 ts/integrations/fjaraskupan/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fjaraskupan/fjaraskupan.classes.client.ts create mode 100644 ts/integrations/fjaraskupan/fjaraskupan.classes.configflow.ts create mode 100644 ts/integrations/fjaraskupan/fjaraskupan.discovery.ts create mode 100644 ts/integrations/fjaraskupan/fjaraskupan.mapper.ts delete mode 100644 ts/integrations/flexit/.generated-by-smarthome-exchange create mode 100644 ts/integrations/flexit/flexit.classes.client.ts create mode 100644 ts/integrations/flexit/flexit.classes.configflow.ts create mode 100644 ts/integrations/flexit/flexit.discovery.ts create mode 100644 ts/integrations/flexit/flexit.mapper.ts diff --git a/test/emoncms_history/test.emoncms_history.node.ts b/test/emoncms_history/test.emoncms_history.node.ts new file mode 100644 index 0000000..cdbffaa --- /dev/null +++ b/test/emoncms_history/test.emoncms_history.node.ts @@ -0,0 +1,71 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EmoncmsHistoryClient, EmoncmsHistoryConfigFlow, EmoncmsHistoryIntegration, EmoncmsHistoryMapper, HomeAssistantEmoncmsHistoryIntegration, createEmoncmsHistoryDiscoveryDescriptor, emoncmsHistoryProfile, type IEmoncmsHistorySnapshot } from '../../ts/integrations/emoncms_history/index.js'; + +const rawData = { + url: 'http://emoncms.local', + inputnode: 12, + payload: { + 'sensor.grid_power': 421.2, + 'sensor.solar_energy': 18.4, + }, + units: { + 'sensor.grid_power': 'W', + 'sensor.solar_energy': 'kWh', + }, +}; + +tap.test('matches manual Emoncms History candidates and creates config flow output', async () => { + const descriptor = createEmoncmsHistoryDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emoncms_history-manual-match'); + const result = await matcher!.matches({ host: 'emoncms.local', name: 'Emoncms History', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('emoncms_history'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EmoncmsHistoryConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('emoncms.local'); + expect(done.config?.path).toEqual('/input/post.json'); +}); + +tap.test('maps Emoncms History raw payload snapshots to runtime devices and entities', async () => { + const client = new EmoncmsHistoryClient({ name: 'History Exporter', rawData, whitelist: ['sensor.grid_power'] }); + const snapshot = await client.getSnapshot(); + const devices = EmoncmsHistoryMapper.toDevices(snapshot); + const entities = EmoncmsHistoryMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('emoncms_history'); + expect(devices[0].manufacturer).toEqual('OpenEnergyMonitor'); + expect(entities.length).toEqual(2); + expect(entities[0].state).toEqual(421.2); + expect(entities[0].attributes?.unit).toEqual('W'); +}); + +tap.test('exposes Emoncms History runtime services, HA alias, and executor-gated control', async () => { + const integration = new EmoncmsHistoryIntegration(); + const alias = new HomeAssistantEmoncmsHistoryIntegration(); + expect(alias.domain).toEqual('emoncms_history'); + expect(integration.status).toEqual('control-runtime'); + expect(emoncmsHistoryProfile.metadata.configFlow).toEqual(false); + expect(emoncmsHistoryProfile.metadata.requirements).toEqual(['pyemoncms==0.1.3']); + + const runtime = await integration.setup({ name: 'History Exporter', rawData }, {}); + const status = await runtime.callService!({ domain: 'emoncms_history', service: 'status', target: {} }); + const snapshot = status.data as IEmoncmsHistorySnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('History Exporter'); + + const command = await runtime.callService!({ domain: 'emoncms_history', service: 'input_post', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/emonitor/test.emonitor.node.ts b/test/emonitor/test.emonitor.node.ts new file mode 100644 index 0000000..4b3870f --- /dev/null +++ b/test/emonitor/test.emonitor.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EmonitorClient, EmonitorConfigFlow, EmonitorIntegration, EmonitorMapper, HomeAssistantEmonitorIntegration, createEmonitorDiscoveryDescriptor, emonitorProfile, type IEmonitorSnapshot } from '../../ts/integrations/emonitor/index.js'; + +const rawData = { + network: { + mac_address: '00:90:C2:12:34:56', + }, + hardware: { + firmware_version: '1.2.3', + serial_number: 'EMON123456', + }, + channels: { + 1: { active: true, label: 'Mains', inst_power: 100, avg_power: 90, max_power: 130, paired_with_channel: 2 }, + 2: { active: true, label: 'Mains B', inst_power: 50, avg_power: 45, max_power: 70, paired_with_channel: 1 }, + 3: { active: false, label: 'Spare', inst_power: 0, avg_power: 0, max_power: 0 }, + 4: { active: true, label: 'Solar', inst_power: 321, avg_power: 300, max_power: 350 }, + }, +}; + +tap.test('matches manual Emonitor candidates and creates config flow output', async () => { + const descriptor = createEmonitorDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emonitor-manual-match'); + const result = await matcher!.matches({ host: 'emonitor.local', name: 'SiteSage Emonitor', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('emonitor'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EmonitorConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('emonitor.local'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Emonitor raw status snapshots to power devices and entities', async () => { + const client = new EmonitorClient({ host: 'emonitor.local', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EmonitorMapper.toDevices(snapshot); + const entities = EmonitorMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('emonitor'); + expect(devices[0].manufacturer).toEqual('Powerhouse Dynamics, Inc.'); + expect(devices[0].name).toEqual('Emonitor 123456'); + expect(entities.length).toEqual(6); + expect(entities[0].state).toEqual(150); + expect(entities[0].attributes?.unit).toEqual('W'); + expect(entities[0].attributes?.deviceClass).toEqual('power'); +}); + +tap.test('exposes Emonitor read-only runtime, HA alias, and unsupported control without executor', async () => { + const integration = new EmonitorIntegration(); + const alias = new HomeAssistantEmonitorIntegration(); + expect(alias.domain).toEqual('emonitor'); + expect(integration.status).toEqual('read-only-runtime'); + expect(emonitorProfile.metadata.configFlow).toEqual(true); + expect(emonitorProfile.metadata.requirements).toEqual(['aioemonitor==1.0.5']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'emonitor', service: 'status', target: {} }); + const snapshot = status.data as IEmonitorSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Emonitor 123456'); + + const command = await runtime.callService!({ domain: 'emonitor', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/emulated_hue/test.emulated_hue.node.ts b/test/emulated_hue/test.emulated_hue.node.ts new file mode 100644 index 0000000..1e8164e --- /dev/null +++ b/test/emulated_hue/test.emulated_hue.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EmulatedHueClient, EmulatedHueConfigFlow, EmulatedHueIntegration, EmulatedHueMapper, HomeAssistantEmulatedHueIntegration, createEmulatedHueDiscoveryDescriptor, emulatedHueProfile, type IEmulatedHueSnapshot } from '../../ts/integrations/emulated_hue/index.js'; + +const rawData = { + config: { + name: 'HASS BRIDGE', + ipaddress: '192.0.2.15:8300', + mac: '00:00:00:00:00:00', + }, + states: [ + { entity_id: 'light.kitchen', state: 'on', attributes: { friendly_name: 'Kitchen Hue', brightness: 128, supported_color_modes: ['brightness'] } }, + { entity_id: 'media_player.living_room', state: 'off', attributes: { friendly_name: 'Living Room', volume_level: 0.4 } }, + ], +}; + +tap.test('matches manual Emulated Hue candidates and creates config flow output', async () => { + const descriptor = createEmulatedHueDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_hue-manual-match'); + const result = await matcher!.matches({ host: '192.0.2.15', name: 'HASS BRIDGE', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('emulated_hue'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EmulatedHueConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.15'); + expect(done.config?.port).toEqual(8300); +}); + +tap.test('maps Emulated Hue raw HA states to bridge devices and entities', async () => { + const client = new EmulatedHueClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = EmulatedHueMapper.toDevices(snapshot); + const entities = EmulatedHueMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('emulated_hue'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(devices[0].name).toEqual('HASS BRIDGE'); + expect(entities.length).toEqual(2); + expect(entities[0].platform).toEqual('light'); + expect(entities[0].state).toEqual(true); + expect(entities[0].attributes?.brightness).toEqual(128); +}); + +tap.test('exposes Emulated Hue runtime services, HA alias, and executor-gated control', async () => { + const integration = new EmulatedHueIntegration(); + const alias = new HomeAssistantEmulatedHueIntegration(); + expect(alias.domain).toEqual('emulated_hue'); + expect(integration.status).toEqual('control-runtime'); + expect(emulatedHueProfile.metadata.configFlow).toEqual(false); + expect(emulatedHueProfile.metadata.dependencies).toEqual(['network']); + expect(emulatedHueProfile.metadata.afterDependencies).toEqual(['http']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'emulated_hue', service: 'status', target: {} }); + const snapshot = status.data as IEmulatedHueSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('HASS BRIDGE'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.kitchen' } }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/emulated_kasa/test.emulated_kasa.node.ts b/test/emulated_kasa/test.emulated_kasa.node.ts new file mode 100644 index 0000000..816744d --- /dev/null +++ b/test/emulated_kasa/test.emulated_kasa.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EmulatedKasaClient, EmulatedKasaConfigFlow, EmulatedKasaIntegration, EmulatedKasaMapper, HomeAssistantEmulatedKasaIntegration, createEmulatedKasaDiscoveryDescriptor, emulatedKasaProfile, type IEmulatedKasaSnapshot } from '../../ts/integrations/emulated_kasa/index.js'; + +const rawData = { + entities: { + 'switch.coffee_maker': { + name: 'Coffee Maker', + unique_id: 'switch.kasa.coffee_maker', + state: 'on', + power: 42.5, + }, + 'sensor.fridge_power': { + name: 'Fridge Power', + domain: 'sensor', + state: '18.2', + }, + }, +}; + +tap.test('matches manual Emulated Kasa candidates and creates config flow output', async () => { + const descriptor = createEmulatedKasaDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_kasa-manual-match'); + const result = await matcher!.matches({ host: 'kasa-emulator.local', name: 'Emulated Kasa', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('emulated_kasa'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EmulatedKasaConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('kasa-emulator.local'); + expect(done.config?.port).toEqual(9999); +}); + +tap.test('maps Emulated Kasa raw snapshots to devices and entities', async () => { + const client = new EmulatedKasaClient({ name: 'Kasa Bridge', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EmulatedKasaMapper.toDevices(snapshot); + const entities = EmulatedKasaMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('emulated_kasa'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(entities.length).toEqual(3); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 42.5)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.state === true)).toBeTrue(); +}); + +tap.test('exposes Emulated Kasa read-only runtime, HA alias, and unsupported control', async () => { + const integration = new EmulatedKasaIntegration(); + const alias = new HomeAssistantEmulatedKasaIntegration(); + expect(alias.domain).toEqual('emulated_kasa'); + expect(integration.status).toEqual('read-only-runtime'); + expect(emulatedKasaProfile.metadata.configFlow).toEqual(false); + expect(emulatedKasaProfile.metadata.requirements).toEqual(['sense-energy==0.14.1']); + + const runtime = await integration.setup({ name: 'Kasa Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'emulated_kasa', service: 'status', target: {} }); + const snapshot = status.data as IEmulatedKasaSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Kasa Runtime'); + + const command = await runtime.callService!({ domain: 'emulated_kasa', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/emulated_roku/test.emulated_roku.node.ts b/test/emulated_roku/test.emulated_roku.node.ts new file mode 100644 index 0000000..497695f --- /dev/null +++ b/test/emulated_roku/test.emulated_roku.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EmulatedRokuClient, EmulatedRokuConfigFlow, EmulatedRokuIntegration, EmulatedRokuMapper, HomeAssistantEmulatedRokuIntegration, createEmulatedRokuDiscoveryDescriptor, emulatedRokuProfile, type IEmulatedRokuSnapshot } from '../../ts/integrations/emulated_roku/index.js'; + +const rawData = { + servers: [ + { + name: 'Living Room Remote', + host_ip: '192.168.1.20', + listen_port: 8060, + advertise_ip: '192.168.1.20', + advertise_port: 8060, + upnp_bind_multicast: true, + state: 'listening', + }, + ], +}; + +tap.test('matches manual Emulated Roku candidates and creates config flow output', async () => { + const descriptor = createEmulatedRokuDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_roku-manual-match'); + const result = await matcher!.matches({ host: 'roku-emulator.local', name: 'Emulated Roku', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('emulated_roku'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EmulatedRokuConfigFlow().start(result.candidate!, {})).submit!({ name: 'Living Room Remote' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('roku-emulator.local'); + expect(done.config?.port).toEqual(8060); +}); + +tap.test('maps Emulated Roku raw snapshots to devices and entities', async () => { + const client = new EmulatedRokuClient({ name: 'Roku Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EmulatedRokuMapper.toDevices(snapshot); + const entities = EmulatedRokuMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('emulated_roku'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(entities.length).toEqual(1); + expect(entities[0].platform).toEqual('sensor'); + expect(entities[0].state).toEqual('listening'); +}); + +tap.test('exposes Emulated Roku read-only runtime, HA alias, and unsupported control', async () => { + const integration = new EmulatedRokuIntegration(); + const alias = new HomeAssistantEmulatedRokuIntegration(); + expect(alias.domain).toEqual('emulated_roku'); + expect(integration.status).toEqual('read-only-runtime'); + expect(emulatedRokuProfile.metadata.configFlow).toEqual(true); + expect(emulatedRokuProfile.metadata.requirements).toEqual(['emulated-roku==0.3.0']); + expect(emulatedRokuProfile.metadata.dependencies).toEqual(['network']); + + const runtime = await integration.setup({ name: 'Roku Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'emulated_roku', service: 'status', target: {} }); + const snapshot = status.data as IEmulatedRokuSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Roku Runtime'); + + const command = await runtime.callService!({ domain: 'emulated_roku', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/energenie_power_sockets/test.energenie_power_sockets.node.ts b/test/energenie_power_sockets/test.energenie_power_sockets.node.ts new file mode 100644 index 0000000..ab335c4 --- /dev/null +++ b/test/energenie_power_sockets/test.energenie_power_sockets.node.ts @@ -0,0 +1,68 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EnergeniePowerSocketsClient, EnergeniePowerSocketsConfigFlow, EnergeniePowerSocketsIntegration, EnergeniePowerSocketsMapper, HomeAssistantEnergeniePowerSocketsIntegration, createEnergeniePowerSocketsDiscoveryDescriptor, energeniePowerSocketsProfile, type IEnergeniePowerSocketsSnapshot } from '../../ts/integrations/energenie_power_sockets/index.js'; + +const rawData = { + device_id: 'EGPS-1234', + name: 'Desk Power Strip', + manufacturer: 'Energenie', + numberOfSockets: 4, + sockets: [true, false, true, false], + sw_version: '0.2.5', +}; + +tap.test('matches manual Energenie Power Sockets candidates and creates config flow output', async () => { + const descriptor = createEnergeniePowerSocketsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'energenie_power_sockets-manual-match'); + const result = await matcher!.matches({ name: 'Energenie Power Sockets', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('energenie_power_sockets'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EnergeniePowerSocketsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Energenie Power Sockets'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Energenie raw snapshots to switch devices and entities', async () => { + const client = new EnergeniePowerSocketsClient({ deviceApiId: 'EGPS-1234', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EnergeniePowerSocketsMapper.toDevices(snapshot); + const entities = EnergeniePowerSocketsMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('energenie_power_sockets'); + expect(devices[0].manufacturer).toEqual('Energenie'); + expect(devices[0].features.length).toEqual(4); + expect(devices[0].features[0].writable).toBeTrue(); + expect(entities.length).toEqual(4); + expect(entities[0].platform).toEqual('switch'); + expect(entities[0].state).toBeTrue(); +}); + +tap.test('exposes Energenie runtime, HA alias, and explicit unsupported control without executor', async () => { + const integration = new EnergeniePowerSocketsIntegration(); + const alias = new HomeAssistantEnergeniePowerSocketsIntegration(); + expect(alias.domain).toEqual('energenie_power_sockets'); + expect(integration.status).toEqual('control-runtime'); + expect(energeniePowerSocketsProfile.metadata.configFlow).toEqual(true); + expect(energeniePowerSocketsProfile.metadata.requirements).toEqual(['pyegps==0.2.5']); + + const runtime = await integration.setup({ deviceApiId: 'EGPS-1234', rawData }, {}); + const status = await runtime.callService!({ domain: 'energenie_power_sockets', service: 'status', target: {} }); + const snapshot = status.data as IEnergeniePowerSocketsSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Desk Power Strip'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.desk_power_strip_socket_0' } }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/enigma2/test.enigma2.node.ts b/test/enigma2/test.enigma2.node.ts new file mode 100644 index 0000000..2c9e48f --- /dev/null +++ b/test/enigma2/test.enigma2.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { Enigma2Client, Enigma2ConfigFlow, Enigma2Integration, Enigma2Mapper, HomeAssistantEnigma2Integration, createEnigma2DiscoveryDescriptor, enigma2Profile, type IEnigma2Snapshot } from '../../ts/integrations/enigma2/index.js'; + +const rawData = { + about: { + info: { + brand: 'Dream Property', + model: 'DM920', + ifaces: [{ mac: 'AA:BB:CC:DD:EE:FF' }], + }, + }, + status: { + in_standby: false, + muted: false, + volume: 35, + is_recording: true, + source_list: ['BBC One HD', 'BBC Two HD'], + currservice: { + station: 'BBC One HD', + name: 'News at Six', + serviceref: '1:0:1:1234:0:0:0:0:0:0:', + fulldescription: 'Evening news', + begin: 1715200000, + end: 1715201800, + }, + }, +}; + +tap.test('matches manual Enigma2 candidates and creates config flow output', async () => { + const descriptor = createEnigma2DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enigma2-manual-match'); + const result = await matcher!.matches({ host: 'enigma2.local', name: 'Living Room Receiver', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('enigma2'); + expect(result.candidate?.port).toEqual(80); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Enigma2ConfigFlow().start(result.candidate!, {})).submit!({ username: 'root', password: 'dreambox' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('enigma2.local'); + expect(done.config?.port).toEqual(80); + expect(done.config?.username).toEqual('root'); +}); + +tap.test('maps Enigma2 raw snapshots to media player devices and entities', async () => { + const client = new Enigma2Client({ host: 'enigma2.local', name: 'Living Room Receiver', rawData }); + const snapshot = await client.getSnapshot(); + const devices = Enigma2Mapper.toDevices(snapshot); + const entities = Enigma2Mapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('enigma2'); + expect(devices[0].manufacturer).toEqual('Dream Property'); + expect(entities.length).toEqual(1); + expect(entities[0].platform).toEqual('media_player'); + expect(entities[0].state).toEqual('on'); + expect(entities[0].attributes?.volumeLevel).toEqual(0.35); +}); + +tap.test('exposes Enigma2 runtime, HA alias, and explicit unsupported control without executor', async () => { + const integration = new Enigma2Integration(); + const alias = new HomeAssistantEnigma2Integration(); + expect(alias.domain).toEqual('enigma2'); + expect(integration.status).toEqual('control-runtime'); + expect(enigma2Profile.metadata.configFlow).toEqual(true); + expect(enigma2Profile.metadata.requirements).toEqual(['openwebifpy==4.3.1']); + + const runtime = await integration.setup({ host: 'enigma2.local', name: 'Living Room Receiver', rawData }, {}); + const status = await runtime.callService!({ domain: 'enigma2', service: 'status', target: {} }); + const snapshot = status.data as IEnigma2Snapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Living Room Receiver'); + + const controlCommand = await runtime.callService!({ domain: 'media_player', service: 'turn_off', target: {} }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/enocean/test.enocean.node.ts b/test/enocean/test.enocean.node.ts new file mode 100644 index 0000000..9099b5d --- /dev/null +++ b/test/enocean/test.enocean.node.ts @@ -0,0 +1,73 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EnoceanClient, EnoceanConfigFlow, EnoceanIntegration, EnoceanMapper, HomeAssistantEnoceanIntegration, createEnoceanDiscoveryDescriptor, enoceanProfile, type IEnoceanSnapshot } from '../../ts/integrations/enocean/index.js'; + +const rawData = { + gateway: { + device: '/dev/ttyUSB0', + manufacturer: 'EnOcean', + model: 'USB 300', + baseAddress: [0, 0, 0, 1], + }, + telegrams: [ + { id: [1, 2, 3, 4], name: 'Window Handle', deviceClass: 'windowhandle', state: 'open' }, + { id: [5, 6, 7, 8], name: 'Room Temperature', deviceClass: 'temperature', state: 21.5 }, + { id: [9, 10, 11, 12], name: 'Relay Channel 0', platform: 'switch', state: true, channel: 0 }, + ], +}; + +tap.test('matches manual EnOcean candidates and creates config flow output', async () => { + const descriptor = createEnoceanDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enocean-manual-match'); + const result = await matcher!.matches({ name: 'EnOcean USB 300', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('enocean'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EnoceanConfigFlow().start(result.candidate!, {})).submit!({ name: 'EnOcean USB 300' }); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('EnOcean USB 300'); + expect(done.config?.transport).toEqual('snapshot'); +}); + +tap.test('maps EnOcean raw snapshots to gateway devices and entities', async () => { + const client = new EnoceanClient({ device: '/dev/ttyUSB0', name: 'EnOcean USB 300', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EnoceanMapper.toDevices(snapshot); + const entities = EnoceanMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('enocean'); + expect(devices[0].manufacturer).toEqual('EnOcean'); + expect(entities.length).toEqual(3); + expect(entities[0].platform).toEqual('binary_sensor'); + expect(entities[0].state).toEqual(true); + expect(entities[0].attributes?.deviceClass).toEqual('opening'); + expect(entities[2].platform).toEqual('switch'); +}); + +tap.test('exposes EnOcean runtime, HA alias, and explicit unsupported control without executor', async () => { + const integration = new EnoceanIntegration(); + const alias = new HomeAssistantEnoceanIntegration(); + expect(alias.domain).toEqual('enocean'); + expect(integration.status).toEqual('control-runtime'); + expect(enoceanProfile.metadata.dependencies).toEqual(['usb']); + expect(enoceanProfile.metadata.requirements).toEqual(['enocean-async==0.4.2']); + + const runtime = await integration.setup({ device: '/dev/ttyUSB0', name: 'EnOcean USB 300', rawData }, {}); + const status = await runtime.callService!({ domain: 'enocean', service: 'status', target: {} }); + const snapshot = status.data as IEnoceanSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('EnOcean USB 300'); + + const controlCommand = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: {} }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/enphase_envoy/test.enphase_envoy.node.ts b/test/enphase_envoy/test.enphase_envoy.node.ts new file mode 100644 index 0000000..4587fc2 --- /dev/null +++ b/test/enphase_envoy/test.enphase_envoy.node.ts @@ -0,0 +1,99 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EnphaseEnvoyClient, EnphaseEnvoyConfigFlow, EnphaseEnvoyIntegration, EnphaseEnvoyMapper, HomeAssistantEnphaseEnvoyIntegration, createEnphaseEnvoyDiscoveryDescriptor, enphaseEnvoyProfile, type IEnphaseEnvoySnapshot } from '../../ts/integrations/enphase_envoy/index.js'; + +const rawData = { + serialNumber: '122334455667', + envoyModel: 'IQ Envoy', + firmware: 'D7.6.175', + partNumber: '800-00554-r01', + system_production: { + watts_now: 420, + watt_hours_today: 3100, + watt_hours_lifetime: 9123456, + }, + system_consumption: { + watts_now: 275, + watt_hours_today: 2200, + watt_hours_lifetime: 8123456, + }, + inverters: { + 'INV-1': { last_report_watts: 210 }, + }, + encharge_aggregate: { + state_of_charge: 82, + reserve_state_of_charge: 30, + }, + enpower: { + serial_number: 'ENP-1', + mains_oper_state: 'closed', + mains_admin_state: 'closed', + firmware_version: '7.6.175', + }, + dry_contact_status: { + relay1: { status: 'closed', name: 'Load Shed Relay' }, + }, + tariff: { + storage_settings: { + charge_from_grid: true, + reserved_soc: 30, + mode: 'backup', + }, + }, +}; + +tap.test('matches manual Enphase Envoy candidates and creates config flow output', async () => { + const descriptor = createEnphaseEnvoyDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enphase_envoy-manual-match'); + const result = await matcher!.matches({ host: 'envoy.local', name: 'Envoy 122334455667', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('enphase_envoy'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EnphaseEnvoyConfigFlow().start(result.candidate!, {})).submit!({ username: 'installer', password: '' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('envoy.local'); + expect(done.config?.path).toEqual('/production.json?details=1'); + expect(done.config?.transport).toEqual('http'); +}); + +tap.test('maps Enphase Envoy raw snapshots to devices and entities', async () => { + const client = new EnphaseEnvoyClient({ host: 'envoy.local', name: 'Envoy 122334455667', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EnphaseEnvoyMapper.toDevices(snapshot); + const entities = EnphaseEnvoyMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('enphase_envoy'); + expect(devices[0].manufacturer).toEqual('Enphase'); + expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_production') && entityArg.state === 420)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'number' && entityArg.name === 'Reserve Battery Level')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'select' && entityArg.state === 'backup')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.name === 'Grid Enabled')).toBeTrue(); +}); + +tap.test('exposes Enphase Envoy runtime, HA alias, and explicit unsupported control without executor', async () => { + const integration = new EnphaseEnvoyIntegration(); + const alias = new HomeAssistantEnphaseEnvoyIntegration(); + expect(alias.domain).toEqual('enphase_envoy'); + expect(integration.status).toEqual('control-runtime'); + expect(enphaseEnvoyProfile.metadata.qualityScale).toEqual('platinum'); + expect(enphaseEnvoyProfile.metadata.requirements).toEqual(['pyenphase==2.4.8']); + + const runtime = await integration.setup({ host: 'envoy.local', name: 'Envoy 122334455667', rawData }, {}); + const status = await runtime.callService!({ domain: 'enphase_envoy', service: 'status', target: {} }); + const snapshot = status.data as IEnphaseEnvoySnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Envoy 122334455667'); + + const controlCommand = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: {} }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/envisalink/test.envisalink.node.ts b/test/envisalink/test.envisalink.node.ts new file mode 100644 index 0000000..a01e4c2 --- /dev/null +++ b/test/envisalink/test.envisalink.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EnvisalinkClient, EnvisalinkConfigFlow, EnvisalinkIntegration, EnvisalinkMapper, HomeAssistantEnvisalinkIntegration, createEnvisalinkDiscoveryDescriptor, envisalinkProfile, type IEnvisalinkSnapshot } from '../../ts/integrations/envisalink/index.js'; + +const rawData = { + device: { + id: 'envisalink-panel-1', + name: 'Envisalink Alarm Panel', + manufacturer: 'EyezOn', + model: 'EVL-4', + }, + entities: [ + { id: 'partition_1_keypad', name: 'Partition 1 Keypad', platform: 'sensor', state: 'Ready', attributes: { armedAway: false, alarm: false } }, + { id: 'front_door', name: 'Front Door', platform: 'binary_sensor', state: false, deviceClass: 'opening', attributes: { zone: 1 } }, + { id: 'front_door_bypass', name: 'Front Door Bypass', platform: 'switch', state: false, writable: true, attributes: { zone: 1 } }, + ], +}; + +tap.test('matches manual Envisalink candidates and creates config flow output', async () => { + const descriptor = createEnvisalinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'envisalink-manual-match'); + const result = await matcher!.matches({ host: 'envisalink.local', name: 'Envisalink', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('envisalink'); + expect(result.candidate?.port).toEqual(4025); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EnvisalinkConfigFlow().start(result.candidate!, {})).submit!({ username: 'user', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('envisalink.local'); + expect(done.config?.port).toEqual(4025); + expect(done.config?.transport).toEqual('tcp'); +}); + +tap.test('maps Envisalink raw snapshots to runtime devices and entities', async () => { + const client = new EnvisalinkClient({ name: 'Envisalink Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EnvisalinkMapper.toDevices(snapshot); + const entities = EnvisalinkMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('envisalink'); + expect(devices[0].manufacturer).toEqual('EyezOn'); + expect(entities.length).toEqual(3); +}); + +tap.test('exposes Envisalink runtime services, HA alias, and unsupported control without executor', async () => { + const integration = new EnvisalinkIntegration(); + const alias = new HomeAssistantEnvisalinkIntegration(); + expect(alias instanceof EnvisalinkIntegration).toBeTrue(); + expect(alias.domain).toEqual('envisalink'); + expect(integration.status).toEqual('control-runtime'); + expect(envisalinkProfile.metadata.requirements).toEqual(['pyenvisalink==4.7']); + expect(envisalinkProfile.metadata.configFlow).toEqual(false); + + const runtime = await integration.setup({ name: 'Envisalink Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'envisalink', service: 'status', target: {} }); + const snapshot = status.data as IEnvisalinkSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Envisalink Alarm Panel'); + expect((await runtime.callService!({ domain: 'envisalink', service: 'refresh', target: {} })).success).toBeTrue(); + + const command = await runtime.callService!({ domain: 'alarm_control_panel', service: 'alarm_arm_away', target: {}, data: { code: '1234' } }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/ephember/test.ephember.node.ts b/test/ephember/test.ephember.node.ts new file mode 100644 index 0000000..a7fac7f --- /dev/null +++ b/test/ephember/test.ephember.node.ts @@ -0,0 +1,73 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EphemberClient, EphemberConfigFlow, EphemberIntegration, EphemberMapper, HomeAssistantEphemberIntegration, createEphemberDiscoveryDescriptor, ephemberProfile, type IEphemberSnapshot } from '../../ts/integrations/ephember/index.js'; + +const rawData = { + device: { + id: 'eph-ember-home-1', + name: 'EPH Ember Home', + manufacturer: 'EPH Controls', + model: 'Ember', + }, + entities: [ + { id: 'living_room', name: 'Living Room', platform: 'climate', state: 'heat_cool', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 21, hvacAction: 'heating' } }, + { id: 'hot_water', name: 'Hot Water', platform: 'climate', state: 'off', writable: true, attributes: { hotWater: true, hvacAction: 'idle' } }, + ], +}; + +tap.test('matches manual Ephember candidates and creates config flow output', async () => { + const descriptor = createEphemberDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ephember-manual-match'); + const result = await matcher!.matches({ name: 'EPH Controls Ember', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('ephember'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EphemberConfigFlow().start(result.candidate!, {})).submit!({ username: 'user@example.com', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.username).toEqual('user@example.com'); + expect(done.config?.transport).toEqual('snapshot'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Ephember raw snapshots to runtime devices and entities', async () => { + const client = new EphemberClient({ name: 'EPH Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EphemberMapper.toDevices(snapshot); + const entities = EphemberMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('ephember'); + expect(devices[0].manufacturer).toEqual('EPH Controls'); + expect(entities.length).toEqual(2); + expect(entities[0].platform).toEqual('climate'); +}); + +tap.test('exposes Ephember runtime services, HA alias, and unsupported control without executor', async () => { + const integration = new EphemberIntegration(); + const alias = new HomeAssistantEphemberIntegration(); + expect(alias instanceof EphemberIntegration).toBeTrue(); + expect(alias.domain).toEqual('ephember'); + expect(integration.status).toEqual('control-runtime'); + expect(ephemberProfile.metadata.requirements).toEqual(['pyephember2==0.4.12']); + expect(ephemberProfile.metadata.configFlow).toEqual(false); + + const runtime = await integration.setup({ name: 'EPH Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'ephember', service: 'status', target: {} }); + const snapshot = status.data as IEphemberSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('EPH Ember Home'); + expect((await runtime.callService!({ domain: 'ephember', service: 'refresh', target: {} })).success).toBeTrue(); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 21.5 } }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/epson/test.epson.node.ts b/test/epson/test.epson.node.ts new file mode 100644 index 0000000..8a07d8a --- /dev/null +++ b/test/epson/test.epson.node.ts @@ -0,0 +1,73 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EpsonClient, EpsonConfigFlow, EpsonIntegration, EpsonMapper, HomeAssistantEpsonIntegration, createEpsonDiscoveryDescriptor, epsonProfile, type IEpsonSnapshot } from '../../ts/integrations/epson/index.js'; + +const rawData = { + device: { + id: 'epson-projector-1', + name: 'Theater Projector', + manufacturer: 'Epson', + model: 'Home Cinema', + serialNumber: 'EPSON12345', + }, + entities: [ + { id: 'projector', name: 'Projector', platform: 'media_player', state: 'on', writable: true, attributes: { source: 'HDMI1', cmode: 'cinema', volumeLevel: 0.35 } }, + ], +}; + +tap.test('matches manual Epson candidates and creates config flow output', async () => { + const descriptor = createEpsonDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'epson-manual-match'); + const result = await matcher!.matches({ host: 'projector.local', name: 'Epson Projector', metadata: { rawData, connectionType: 'http' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('epson'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EpsonConfigFlow().start(result.candidate!, {})).submit!({ name: 'Theater Projector' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('projector.local'); + expect(done.config?.name).toEqual('Theater Projector'); + expect(done.config?.metadata?.connectionType).toEqual('http'); +}); + +tap.test('maps Epson raw snapshots to runtime devices and entities', async () => { + const client = new EpsonClient({ name: 'Epson Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EpsonMapper.toDevices(snapshot); + const entities = EpsonMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('epson'); + expect(devices[0].manufacturer).toEqual('Epson'); + expect(entities.length).toEqual(1); + expect(entities[0].platform).toEqual('media_player'); +}); + +tap.test('exposes Epson runtime services, HA alias, and unsupported control without executor', async () => { + const integration = new EpsonIntegration(); + const alias = new HomeAssistantEpsonIntegration(); + expect(alias instanceof EpsonIntegration).toBeTrue(); + expect(alias.domain).toEqual('epson'); + expect(integration.status).toEqual('control-runtime'); + expect(epsonProfile.metadata.requirements).toEqual(['epson-projector==0.6.0']); + expect(epsonProfile.metadata.configFlow).toEqual(true); + + const runtime = await integration.setup({ name: 'Epson Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'epson', service: 'status', target: {} }); + const snapshot = status.data as IEpsonSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Theater Projector'); + expect((await runtime.callService!({ domain: 'epson', service: 'refresh', target: {} })).success).toBeTrue(); + + const command = await runtime.callService!({ domain: 'epson', service: 'select_cmode', target: {}, data: { cmode: 'cinema' } }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/eq3btsmart/test.eq3btsmart.node.ts b/test/eq3btsmart/test.eq3btsmart.node.ts new file mode 100644 index 0000000..802295b --- /dev/null +++ b/test/eq3btsmart/test.eq3btsmart.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { Eq3btsmartClient, Eq3btsmartConfigFlow, Eq3btsmartIntegration, Eq3btsmartMapper, HomeAssistantEq3btsmartIntegration, createEq3btsmartDiscoveryDescriptor, eq3btsmartProfile, type IEq3btsmartSnapshot } from '../../ts/integrations/eq3btsmart/index.js'; + +const rawData = { + device: { + id: 'eq3-aa-bb-cc-dd-ee-ff', + name: 'Hall Radiator Valve', + manufacturer: 'eQ-3 AG', + model: 'CC-RT-BLE-EQ', + serialNumber: 'AA:BB:CC:DD:EE:FF', + attributes: { + connection: 'bluetooth', + }, + }, + entities: [ + { id: 'thermostat', name: 'Thermostat', platform: 'climate', state: { currentTemperature: 20.5, targetTemperature: 21, hvacMode: 'heat', presetMode: 'none' }, writable: true, unit: 'C' }, + { id: 'low_battery', name: 'Low Battery', platform: 'binary_sensor', state: false, deviceClass: 'battery' }, + { id: 'window', name: 'Window', platform: 'binary_sensor', state: false, deviceClass: 'window' }, + { id: 'valve', name: 'Valve', platform: 'sensor', state: 34, unit: '%', stateClass: 'measurement' }, + { id: 'comfort', name: 'Comfort Temperature', platform: 'number', state: 21, unit: 'C', writable: true }, + { id: 'boost', name: 'Boost', platform: 'switch', state: false, writable: true }, + ], +}; + +tap.test('matches manual eQ-3 candidates and creates config flow output', async () => { + const descriptor = createEq3btsmartDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eq3btsmart-manual-match'); + const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'CC-RT-BLE Hall', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('eq3btsmart'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Eq3btsmartConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps eQ-3 raw snapshots to runtime devices and entities', async () => { + const client = new Eq3btsmartClient({ name: 'Hall Radiator Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = Eq3btsmartMapper.toDevices(snapshot); + const entities = Eq3btsmartMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('eq3btsmart'); + expect(devices[0].manufacturer).toEqual('eQ-3 AG'); + expect(entities.some((entityArg) => entityArg.platform === 'climate')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'number')).toBeTrue(); +}); + +tap.test('exposes eQ-3 runtime services, HA alias, and unsupported control without executor', async () => { + const alias = new HomeAssistantEq3btsmartIntegration(); + expect(alias instanceof Eq3btsmartIntegration).toBeTrue(); + expect(alias.domain).toEqual('eq3btsmart'); + expect(eq3btsmartProfile.metadata.iotClass).toEqual('local_polling'); + expect(eq3btsmartProfile.metadata.requirements).toEqual(['eq3btsmart==2.3.0']); + expect(eq3btsmartProfile.metadata.configFlow).toBeTrue(); + + const runtime = await new Eq3btsmartIntegration().setup({ name: 'Hall Radiator Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'eq3btsmart', service: 'status', target: {} }); + const snapshot = status.data as IEq3btsmartSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.callService!({ domain: 'eq3btsmart', service: 'refresh', target: {} })).success).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Hall Radiator Valve'); + + const controlCommand = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 20 } }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/escea/test.escea.node.ts b/test/escea/test.escea.node.ts new file mode 100644 index 0000000..03a4e42 --- /dev/null +++ b/test/escea/test.escea.node.ts @@ -0,0 +1,71 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EsceaClient, EsceaConfigFlow, EsceaIntegration, EsceaMapper, HomeAssistantEsceaIntegration, createEsceaDiscoveryDescriptor, esceaProfile, type IEsceaSnapshot } from '../../ts/integrations/escea/index.js'; + +const rawData = { + device: { + id: 'escea-controller-1', + name: 'Living Room Fireplace', + manufacturer: 'Escea', + model: 'Escea Fireplace', + serialNumber: 'ESCEA-001', + }, + entities: [ + { id: 'fireplace', name: 'Fireplace', platform: 'climate', state: { hvacMode: 'heat', currentTemperature: 19, targetTemperature: 22, fanMode: 'auto' }, writable: true, unit: 'C' }, + ], +}; + +tap.test('matches manual Escea candidates and creates config flow output', async () => { + const descriptor = createEsceaDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'escea-manual-match'); + const result = await matcher!.matches({ name: 'Escea Fireplace', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('escea'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EsceaConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Escea Fireplace'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Escea raw snapshots to runtime devices and entities', async () => { + const client = new EsceaClient({ name: 'Escea Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EsceaMapper.toDevices(snapshot); + const entities = EsceaMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('escea'); + expect(devices[0].manufacturer).toEqual('Escea'); + expect(entities.length).toEqual(1); + expect(entities[0].platform).toEqual('climate'); +}); + +tap.test('exposes Escea runtime services, HA alias, and unsupported control without executor', async () => { + const alias = new HomeAssistantEsceaIntegration(); + expect(alias instanceof EsceaIntegration).toBeTrue(); + expect(alias.domain).toEqual('escea'); + expect(esceaProfile.metadata.iotClass).toEqual('local_push'); + expect(esceaProfile.metadata.requirements).toEqual(['pescea==1.0.12']); + expect(esceaProfile.metadata.configFlow).toBeTrue(); + + const runtime = await new EsceaIntegration().setup({ name: 'Escea Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'escea', service: 'status', target: {} }); + const snapshot = status.data as IEsceaSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.callService!({ domain: 'escea', service: 'snapshot', target: {} })).success).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Living Room Fireplace'); + + const controlCommand = await runtime.callService!({ domain: 'climate', service: 'set_fan_mode', target: {}, data: { fanMode: 'auto' } }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/eufy/test.eufy.node.ts b/test/eufy/test.eufy.node.ts new file mode 100644 index 0000000..9eb6418 --- /dev/null +++ b/test/eufy/test.eufy.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EufyClient, EufyConfigFlow, EufyIntegration, EufyMapper, HomeAssistantEufyIntegration, createEufyDiscoveryDescriptor, eufyProfile, type IEufySnapshot } from '../../ts/integrations/eufy/index.js'; + +const rawData = { + device: { + id: 'eufy-home-1', + name: 'Eufy Home Devices', + manufacturer: 'Eufy', + model: 'EufyHome', + host: '192.0.2.24', + }, + entities: [ + { id: 'color_bulb', name: 'Color Bulb', platform: 'light', state: true, writable: true, attributes: { type: 'T1013', brightness: 182, colorTempKelvin: 3200, hsColor: [32, 72] } }, + { id: 'smart_plug', name: 'Smart Plug', platform: 'switch', state: false, writable: true, attributes: { type: 'T1201' } }, + ], +}; + +tap.test('matches manual EufyHome candidates and creates config flow output', async () => { + const descriptor = createEufyDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eufy-manual-match'); + const result = await matcher!.matches({ host: '192.0.2.24', name: 'EufyHome Color Bulb', metadata: { rawData, type: 'T1013', accessToken: 'token' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('eufy'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EufyConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.24'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps EufyHome raw snapshots to runtime devices and entities', async () => { + const client = new EufyClient({ name: 'Eufy Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EufyMapper.toDevices(snapshot); + const entities = EufyMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('eufy'); + expect(devices[0].manufacturer).toEqual('Eufy'); + expect(entities.some((entityArg) => entityArg.platform === 'light')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch')).toBeTrue(); +}); + +tap.test('exposes EufyHome runtime services, HA alias, and unsupported control without executor', async () => { + const alias = new HomeAssistantEufyIntegration(); + expect(alias instanceof EufyIntegration).toBeTrue(); + expect(alias.domain).toEqual('eufy'); + expect(eufyProfile.metadata.qualityScale).toEqual('legacy'); + expect(eufyProfile.metadata.requirements).toEqual(['lakeside==0.13']); + expect(eufyProfile.metadata.configFlow).toEqual(false); + + const runtime = await new EufyIntegration().setup({ name: 'Eufy Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'eufy', service: 'status', target: {} }); + const snapshot = status.data as IEufySnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.callService!({ domain: 'eufy', service: 'refresh', target: {} })).success).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Eufy Home Devices'); + + const controlCommand = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 128 } }); + expect(controlCommand.success).toBeFalse(); + expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/eufylife_ble/test.eufylife_ble.node.ts b/test/eufylife_ble/test.eufylife_ble.node.ts new file mode 100644 index 0000000..d58b2b7 --- /dev/null +++ b/test/eufylife_ble/test.eufylife_ble.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EufylifeBleClient, EufylifeBleConfigFlow, EufylifeBleIntegration, EufylifeBleMapper, HomeAssistantEufylifeBleIntegration, createEufylifeBleDiscoveryDescriptor, eufylifeBleProfile, type IEufylifeBleSnapshot } from '../../ts/integrations/eufylife_ble/index.js'; + +const rawData = { + device: { + id: 'eufy-scale-aabbccddeeff', + name: 'Eufy Smart Scale', + manufacturer: 'Eufy', + model: 'eufy T9148', + serialNumber: 'AA:BB:CC:DD:EE:FF', + protocol: 'local', + }, + entities: [ + { id: 'weight', name: 'Weight', platform: 'sensor', state: 72.4, unit: 'kg', deviceClass: 'weight', stateClass: 'measurement' }, + { id: 'real_time_weight', name: 'Real-time weight', platform: 'sensor', state: 72.1, unit: 'kg', deviceClass: 'weight', stateClass: 'measurement' }, + { id: 'heart_rate', name: 'Heart rate', platform: 'sensor', state: 68, unit: 'bpm' }, + ], +}; + +tap.test('matches manual EufyLife BLE candidates and creates config flow output', async () => { + const descriptor = createEufylifeBleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eufylife_ble-manual-match'); + const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'eufy T9148', metadata: { address: 'AA:BB:CC:DD:EE:FF', model: 'eufy T9148', rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('eufylife_ble'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EufylifeBleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF'); + expect(done.config?.metadata?.model).toEqual('eufy T9148'); +}); + +tap.test('maps EufyLife BLE raw snapshots to runtime devices and entities', async () => { + const client = new EufylifeBleClient({ name: 'EufyLife Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EufylifeBleMapper.toDevices(snapshot); + const entities = EufylifeBleMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('eufylife_ble'); + expect(devices[0].manufacturer).toEqual('Eufy'); + expect(entities.length).toEqual(3); +}); + +tap.test('exposes EufyLife BLE read-only runtime, HA alias, and unsupported control', async () => { + const integration = new EufylifeBleIntegration(); + const alias = new HomeAssistantEufylifeBleIntegration(); + expect(alias.domain).toEqual('eufylife_ble'); + expect(integration.status).toEqual('read-only-runtime'); + expect(eufylifeBleProfile.metadata.configFlow).toEqual(true); + expect(eufylifeBleProfile.metadata.requirements).toEqual(['eufylife-ble-client==0.1.8']); + + const runtime = await integration.setup({ name: 'EufyLife Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'eufylife_ble', service: 'status', target: {} }); + const snapshot = status.data as IEufylifeBleSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Eufy Smart Scale'); + + const command = await runtime.callService!({ domain: 'eufylife_ble', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error)).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/eurotronic_cometblue/test.eurotronic_cometblue.node.ts b/test/eurotronic_cometblue/test.eurotronic_cometblue.node.ts new file mode 100644 index 0000000..29a162e --- /dev/null +++ b/test/eurotronic_cometblue/test.eurotronic_cometblue.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EurotronicCometblueClient, EurotronicCometblueConfigFlow, EurotronicCometblueIntegration, EurotronicCometblueMapper, HomeAssistantEurotronicCometblueIntegration, createEurotronicCometblueDiscoveryDescriptor, eurotronicCometblueProfile, type IEurotronicCometblueSnapshot } from '../../ts/integrations/eurotronic_cometblue/index.js'; + +const rawData = { + device: { + id: 'cometblue-aabbccddeeff', + name: 'Comet Blue AA:BB', + manufacturer: 'Eurotronic', + model: 'Comet Blue', + serialNumber: 'AA:BB:CC:DD:EE:FF', + protocol: 'local', + }, + entities: [ + { id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'auto', writable: true, attributes: { currentTemperature: 21.5, targetTemperature: 20, hvacModes: ['auto', 'heat', 'off'], presetMode: 'comfort' } }, + { id: 'battery', name: 'Battery', platform: 'sensor', state: 86, unit: '%', deviceClass: 'battery' }, + { id: 'sync_time', name: 'Sync time', platform: 'button', state: 'available', writable: true }, + ], +}; + +tap.test('matches manual Eurotronic Comet Blue candidates and creates config flow output', async () => { + const descriptor = createEurotronicCometblueDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eurotronic_cometblue-manual-match'); + const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Comet Blue AA:BB', metadata: { address: 'AA:BB:CC:DD:EE:FF', pin: '000000', rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('eurotronic_cometblue'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EurotronicCometblueConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF'); + expect(done.config?.metadata?.pin).toEqual('000000'); +}); + +tap.test('maps Eurotronic Comet Blue raw snapshots to runtime devices and entities', async () => { + const client = new EurotronicCometblueClient({ name: 'Comet Blue Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EurotronicCometblueMapper.toDevices(snapshot); + const entities = EurotronicCometblueMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('eurotronic_cometblue'); + expect(devices[0].manufacturer).toEqual('Eurotronic'); + expect(entities.length).toEqual(3); +}); + +tap.test('exposes Eurotronic Comet Blue runtime, HA alias, and unsupported control', async () => { + const integration = new EurotronicCometblueIntegration(); + const alias = new HomeAssistantEurotronicCometblueIntegration(); + expect(alias.domain).toEqual('eurotronic_cometblue'); + expect(integration.status).toEqual('control-runtime'); + expect(eurotronicCometblueProfile.metadata.qualityScale).toEqual('bronze'); + expect(eurotronicCometblueProfile.metadata.requirements).toEqual(['eurotronic-cometblue-ha==1.4.0']); + + const runtime = await integration.setup({ name: 'Comet Blue Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'eurotronic_cometblue', service: 'status', target: {} }); + const snapshot = status.data as IEurotronicCometblueSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Comet Blue AA:BB'); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 20.5 } }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error)).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/everlights/test.everlights.node.ts b/test/everlights/test.everlights.node.ts new file mode 100644 index 0000000..f419ad9 --- /dev/null +++ b/test/everlights/test.everlights.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EverlightsClient, EverlightsConfigFlow, EverlightsIntegration, EverlightsMapper, HomeAssistantEverlightsIntegration, createEverlightsDiscoveryDescriptor, everlightsProfile, type IEverlightsSnapshot } from '../../ts/integrations/everlights/index.js'; + +const rawData = { + device: { + id: 'everlights-001122334455', + name: 'EverLights 00:11:22:33:44:55', + manufacturer: 'EverLights', + model: 'EverLights controller', + serialNumber: '00:11:22:33:44:55', + host: '192.0.2.20', + protocol: 'http', + }, + entities: [ + { id: 'zone_1', name: 'Zone 1', platform: 'light', state: true, writable: true, attributes: { brightness: 255, hsColor: [120, 80], effect: 'Rainbow', effectList: ['Rainbow', 'Solid'] } }, + { id: 'zone_2', name: 'Zone 2', platform: 'light', state: false, writable: true, attributes: { brightness: 0, effectList: ['Rainbow', 'Solid'] } }, + ], +}; + +tap.test('matches manual EverLights candidates and creates config flow output', async () => { + const descriptor = createEverlightsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'everlights-manual-match'); + const result = await matcher!.matches({ host: '192.0.2.20', name: 'EverLights Controller', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('everlights'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EverlightsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.20'); + expect(done.config?.transport).toEqual('snapshot'); +}); + +tap.test('maps EverLights raw snapshots to runtime devices and entities', async () => { + const client = new EverlightsClient({ name: 'EverLights Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EverlightsMapper.toDevices(snapshot); + const entities = EverlightsMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('everlights'); + expect(devices[0].manufacturer).toEqual('EverLights'); + expect(entities.length).toEqual(2); +}); + +tap.test('exposes EverLights runtime, HA alias, and unsupported control', async () => { + const integration = new EverlightsIntegration(); + const alias = new HomeAssistantEverlightsIntegration(); + expect(alias.domain).toEqual('everlights'); + expect(integration.status).toEqual('control-runtime'); + expect(everlightsProfile.metadata.configFlow).toEqual(false); + expect(everlightsProfile.metadata.requirements).toEqual(['pyeverlights==0.1.0']); + + const runtime = await integration.setup({ name: 'EverLights Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'everlights', service: 'status', target: {} }); + const snapshot = status.data as IEverlightsSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('EverLights 00:11:22:33:44:55'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 255 } }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error)).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/evil_genius_labs/test.evil_genius_labs.node.ts b/test/evil_genius_labs/test.evil_genius_labs.node.ts new file mode 100644 index 0000000..8a940c7 --- /dev/null +++ b/test/evil_genius_labs/test.evil_genius_labs.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EvilGeniusLabsClient, EvilGeniusLabsConfigFlow, EvilGeniusLabsIntegration, EvilGeniusLabsMapper, HomeAssistantEvilGeniusLabsIntegration, createEvilGeniusLabsDiscoveryDescriptor, evilGeniusLabsProfile, type IEvilGeniusLabsSnapshot } from '../../ts/integrations/evil_genius_labs/index.js'; + +const rawData = { + device: { + id: 'egl-wifi-123', + name: 'Living Room Fibonacci', + manufacturer: 'Evil Genius Labs', + model: 'Fibonacci64', + }, + entities: [ + { + id: 'light', + name: 'Living Room Fibonacci', + platform: 'light', + state: true, + attributes: { + brightness: 180, + effect: 'Rainbow', + rgbColor: [12, 34, 56], + }, + }, + ], +}; + +tap.test('matches manual Evil Genius Labs candidates and creates config flow output', async () => { + const descriptor = createEvilGeniusLabsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'evil_genius_labs-manual-match'); + const result = await matcher!.matches({ host: 'evilgenius.local', name: 'Evil Genius Labs', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('evil_genius_labs'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new EvilGeniusLabsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('evilgenius.local'); + expect(done.config?.path).toEqual('/all'); +}); + +tap.test('maps Evil Genius Labs raw snapshots to runtime devices and entities', async () => { + const client = new EvilGeniusLabsClient({ name: 'Evil Genius Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = EvilGeniusLabsMapper.toDevices(snapshot); + const entities = EvilGeniusLabsMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('evil_genius_labs'); + expect(devices[0].manufacturer).toEqual('Evil Genius Labs'); + expect(entities[0].platform).toEqual('light'); +}); + +tap.test('exposes Evil Genius Labs runtime services, HA alias, and executor-gated controls', async () => { + const integration = new EvilGeniusLabsIntegration(); + const alias = new HomeAssistantEvilGeniusLabsIntegration(); + expect(alias.domain).toEqual('evil_genius_labs'); + expect(integration.status).toEqual('control-runtime'); + expect(evilGeniusLabsProfile.metadata.configFlow).toEqual(true); + expect(evilGeniusLabsProfile.metadata.requirements).toEqual(['pyevilgenius==2.0.0']); + expect(Object.prototype.hasOwnProperty.call(evilGeniusLabsProfile.metadata, 'qualityScale')).toBeTrue(); + expect(evilGeniusLabsProfile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ name: 'Evil Genius Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'light', service: 'status', target: {} }); + const snapshot = status.data as IEvilGeniusLabsSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Living Room Fibonacci'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/fail2ban/test.fail2ban.node.ts b/test/fail2ban/test.fail2ban.node.ts new file mode 100644 index 0000000..0d585dc --- /dev/null +++ b/test/fail2ban/test.fail2ban.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { Fail2banClient, Fail2banConfigFlow, Fail2banIntegration, Fail2banMapper, HomeAssistantFail2banIntegration, createFail2banDiscoveryDescriptor, fail2banProfile, type IFail2banSnapshot } from '../../ts/integrations/fail2ban/index.js'; + +const rawData = { + device: { + id: 'fail2ban-host', + name: 'Fail2Ban Host', + }, + entities: [ + { + id: 'ssh_current_bans', + name: 'fail2ban ssh', + platform: 'sensor', + state: '203.0.113.15', + attributes: { + current_bans: ['203.0.113.15'], + total_bans: ['198.51.100.4', '203.0.113.15'], + jail: 'ssh', + }, + }, + ], +}; + +tap.test('matches manual Fail2Ban candidates and creates config flow output', async () => { + const descriptor = createFail2banDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fail2ban-manual-match'); + const result = await matcher!.matches({ name: 'Fail2Ban', metadata: { rawData, jails: ['ssh'], filePath: '/var/log/fail2ban.log' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fail2ban'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Fail2banConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Fail2Ban raw snapshots to runtime devices and entities', async () => { + const client = new Fail2banClient({ name: 'Fail2Ban Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = Fail2banMapper.toDevices(snapshot); + const entities = Fail2banMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fail2ban'); + expect(devices[0].model).toEqual('Fail2Ban log parser'); + expect(entities[0].state).toEqual('203.0.113.15'); +}); + +tap.test('exposes Fail2Ban read-only runtime, HA alias, and unsupported control', async () => { + const integration = new Fail2banIntegration(); + const alias = new HomeAssistantFail2banIntegration(); + expect(alias.domain).toEqual('fail2ban'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fail2banProfile.metadata.configFlow).toEqual(false); + expect(fail2banProfile.metadata.qualityScale).toEqual('legacy'); + expect(fail2banProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: 'Fail2Ban Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'fail2ban', service: 'status', target: {} }); + const snapshot = status.data as IFail2banSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Fail2Ban Host'); + + const command = await runtime.callService!({ domain: 'fail2ban', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/familyhub/test.familyhub.node.ts b/test/familyhub/test.familyhub.node.ts new file mode 100644 index 0000000..2343142 --- /dev/null +++ b/test/familyhub/test.familyhub.node.ts @@ -0,0 +1,81 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FamilyhubClient, FamilyhubConfigFlow, FamilyhubIntegration, FamilyhubMapper, HomeAssistantFamilyhubIntegration, createFamilyhubDiscoveryDescriptor, familyhubProfile, type IFamilyhubSnapshot } from '../../ts/integrations/familyhub/index.js'; + +const rawData = { + device: { + id: 'familyhub-kitchen', + name: 'Kitchen Family Hub', + manufacturer: 'Samsung', + model: 'Family Hub refrigerator', + host: '192.0.2.44', + port: 17654, + }, + entities: [ + { + id: 'camera_info', + name: 'Kitchen FamilyHub Camera', + platform: 'sensor', + state: 'available', + attributes: { + glazeUrls: ['/camera1.jpg', '/camera2.jpg'], + stillImagePath: '/.krate/owner/share/scloud/glazeCameraInfo.txt', + }, + }, + ], +}; + +tap.test('matches manual Family Hub candidates and creates config flow output', async () => { + const descriptor = createFamilyhubDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'familyhub-manual-match'); + const result = await matcher!.matches({ host: 'familyhub.local', name: 'Samsung Family Hub', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('familyhub'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FamilyhubConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('familyhub.local'); + expect(done.config?.port).toEqual(17654); + expect(done.config?.path).toEqual('/.krate/owner/share/scloud/glazeCameraInfo.txt'); +}); + +tap.test('maps Family Hub raw snapshots to runtime devices and entities', async () => { + const client = new FamilyhubClient({ name: 'Family Hub Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const devices = FamilyhubMapper.toDevices(snapshot); + const entities = FamilyhubMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('familyhub'); + expect(devices[0].manufacturer).toEqual('Samsung'); + expect(entities[0].state).toEqual('available'); +}); + +tap.test('exposes Family Hub read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FamilyhubIntegration(); + const alias = new HomeAssistantFamilyhubIntegration(); + expect(alias.domain).toEqual('familyhub'); + expect(integration.status).toEqual('read-only-runtime'); + expect(familyhubProfile.metadata.configFlow).toEqual(false); + expect(familyhubProfile.metadata.qualityScale).toEqual('legacy'); + expect(familyhubProfile.metadata.requirements).toEqual(['python-family-hub-local==0.0.2']); + + const runtime = await integration.setup({ name: 'Family Hub Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'camera', service: 'status', target: {} }); + const snapshot = status.data as IFamilyhubSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Kitchen Family Hub'); + + const command = await runtime.callService!({ domain: 'camera', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/fibaro/test.fibaro.node.ts b/test/fibaro/test.fibaro.node.ts new file mode 100644 index 0000000..c6dfca9 --- /dev/null +++ b/test/fibaro/test.fibaro.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FibaroClient, FibaroConfigFlow, FibaroIntegration, FibaroMapper, HomeAssistantFibaroIntegration, createFibaroDiscoveryDescriptor, fibaroProfile, type IFibaroSnapshot } from '../../ts/integrations/fibaro/index.js'; + +const rawData = { + info: { + serial_number: 'HC3-12345', + hc_name: 'Home Center 3', + manufacturer_name: 'Fibaro', + model_name: 'Home Center 3', + current_version: '5.150.18', + mac_address: '00:11:22:33:44:55', + }, + devices: [ + { fibaro_id: 11, name: 'Temperature', room_name: 'Kitchen', type: 'com.fibaro.temperatureSensor', value: '21.4', unit: 'C', properties: { value: '21.4' } }, + { fibaro_id: 12, name: 'Motion', room_name: 'Hall', type: 'com.fibaro.motionSensor', value: true, properties: { value: true } }, + { fibaro_id: 13, name: 'Wall Plug', room_name: 'Living', type: 'com.fibaro.binarySwitch', value: false, actions: ['turnOn', 'turnOff'], properties: { value: false, power: '12.5', energy: '1.2' } }, + ], + scenes: [ + { fibaro_id: 50, name: 'Evening', room_id: 1, visible: true }, + ], +}; + +tap.test('matches manual Fibaro candidates and creates config flow output', async () => { + const descriptor = createFibaroDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fibaro-manual-match'); + const result = await matcher!.matches({ host: 'fibaro.local', name: 'Fibaro Home Center', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fibaro'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FibaroConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('fibaro.local'); + expect(done.config?.path).toEqual('/api/'); + expect(done.config?.username).toEqual('admin'); +}); + +tap.test('maps Fibaro raw snapshots to hub devices and entities', async () => { + const client = new FibaroClient({ host: 'fibaro.local', rawData }); + const snapshot = await client.getSnapshot(); + const devices = FibaroMapper.toDevices(snapshot); + const entities = FibaroMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.serialNumber).toEqual('HC3-12345'); + expect(devices[0].integrationDomain).toEqual('fibaro'); + expect(devices[0].manufacturer).toEqual('Fibaro'); + expect(entities.length).toEqual(6); + expect(entities.some((entityArg) => entityArg.attributes?.deviceClass === 'temperature')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'button')).toBeTrue(); +}); + +tap.test('exposes Fibaro read-only runtime, HA alias, and explicit unsupported control without executor', async () => { + const integration = new FibaroIntegration(); + const alias = new HomeAssistantFibaroIntegration(); + expect(alias.domain).toEqual('fibaro'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fibaroProfile.metadata.configFlow).toEqual(true); + expect(fibaroProfile.metadata.requirements).toEqual(['pyfibaro==0.8.3']); + expect(Object.prototype.hasOwnProperty.call(fibaroProfile.metadata, 'qualityScale')).toBeTrue(); + expect(fibaroProfile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ host: 'fibaro.local', rawData }, {}); + const status = await runtime.callService!({ domain: 'fibaro', service: 'status', target: {} }); + const snapshot = status.data as IFibaroSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Home Center 3'); + + const command = await runtime.callService!({ domain: 'fibaro', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/file/test.file.node.ts b/test/file/test.file.node.ts new file mode 100644 index 0000000..a2673b9 --- /dev/null +++ b/test/file/test.file.node.ts @@ -0,0 +1,66 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FileClient, FileConfigFlow, FileIntegration, FileMapper, HomeAssistantFileIntegration, createFileDiscoveryDescriptor, fileProfile, type IFileSnapshot } from '../../ts/integrations/file/index.js'; + +const rawData = { + filePath: 'test/file/sample.log', + latestEntry: '42.5', + contentBytes: 24, +}; + +tap.test('matches manual File candidates and creates config flow output', async () => { + const descriptor = createFileDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'file-manual-match'); + const result = await matcher!.matches({ name: 'Local file sensor', metadata: { rawData, filePath: 'test/file/sample.log', platform: 'sensor' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('file'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FileConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); + expect(done.config?.metadata?.filePath).toEqual('test/file/sample.log'); +}); + +tap.test('maps File raw snapshots to devices and entities', async () => { + const client = new FileClient({ name: 'Temperature Log', rawData, unitOfMeasurement: 'C' }); + const snapshot = await client.getSnapshot(); + const devices = FileMapper.toDevices(snapshot); + const entities = FileMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('file'); + expect(devices[0].model).toEqual('Local File'); + expect(entities.length).toEqual(1); + expect(entities[0].state).toEqual('42.5'); + expect(entities[0].attributes?.unit).toEqual('C'); +}); + +tap.test('reads local File snapshots and exposes read-only runtime with unsupported control', async () => { + const integration = new FileIntegration(); + const alias = new HomeAssistantFileIntegration(); + expect(alias.domain).toEqual('file'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fileProfile.metadata.configFlow).toEqual(true); + expect(fileProfile.metadata.requirements).toEqual(['file-read-backwards==2.0.0']); + expect(Object.prototype.hasOwnProperty.call(fileProfile.metadata, 'qualityScale')).toBeTrue(); + expect(fileProfile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ name: 'File Runtime', filePath: 'test/file/sample.log' }, {}); + const status = await runtime.callService!({ domain: 'file', service: 'status', target: {} }); + const snapshot = status.data as IFileSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.entities[0].state).toEqual('latest value'); + expect((await runtime.devices())[0].name).toEqual('File Runtime'); + + const command = await runtime.callService!({ domain: 'file', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/filesize/sample.txt b/test/filesize/sample.txt new file mode 100644 index 0000000..a32a434 --- /dev/null +++ b/test/filesize/sample.txt @@ -0,0 +1 @@ +1234567890 diff --git a/test/filesize/test.filesize.node.ts b/test/filesize/test.filesize.node.ts new file mode 100644 index 0000000..705ad40 --- /dev/null +++ b/test/filesize/test.filesize.node.ts @@ -0,0 +1,69 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FilesizeClient, FilesizeConfigFlow, FilesizeIntegration, FilesizeMapper, HomeAssistantFilesizeIntegration, createFilesizeDiscoveryDescriptor, filesizeProfile, type IFilesizeSnapshot } from '../../ts/integrations/filesize/index.js'; + +const rawData = { + filePath: 'test/filesize/sample.txt', + file: 1.54, + bytes: 1536000, + lastUpdated: '2026-01-02T03:04:05.000Z', + created: '2026-01-01T03:04:05.000Z', +}; + +tap.test('matches manual File Size candidates and creates config flow output', async () => { + const descriptor = createFilesizeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'filesize-manual-match'); + const result = await matcher!.matches({ name: 'File Size sensor', metadata: { rawData, filePath: 'test/filesize/sample.txt' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('filesize'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FilesizeConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); + expect(done.config?.metadata?.filePath).toEqual('test/filesize/sample.txt'); +}); + +tap.test('maps File Size raw snapshots to devices and entities', async () => { + const client = new FilesizeClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FilesizeMapper.toDevices(snapshot); + const entities = FilesizeMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('filesize'); + expect(devices[0].model).toEqual('Local File Statistics'); + expect(entities.length).toEqual(4); + expect(entities[0].state).toEqual(1.54); + expect(entities[1].attributes?.unit).toEqual('B'); + expect(entities[2].attributes?.deviceClass).toEqual('timestamp'); +}); + +tap.test('reads local File Size snapshots and exposes read-only runtime with unsupported control', async () => { + const integration = new FilesizeIntegration(); + const alias = new HomeAssistantFilesizeIntegration(); + expect(alias.domain).toEqual('filesize'); + expect(integration.status).toEqual('read-only-runtime'); + expect(filesizeProfile.metadata.configFlow).toEqual(true); + expect(filesizeProfile.metadata.requirements).toEqual([]); + expect(Object.prototype.hasOwnProperty.call(filesizeProfile.metadata, 'qualityScale')).toBeTrue(); + expect(filesizeProfile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ filePath: 'test/filesize/sample.txt' }, {}); + const status = await runtime.callService!({ domain: 'filesize', service: 'status', target: {} }); + const snapshot = status.data as IFilesizeSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(Number(snapshot.entities.find((entityArg) => entityArg.id === 'bytes')?.state) > 0).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('sample.txt'); + + const command = await runtime.callService!({ domain: 'filesize', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/fing/test.fing.node.ts b/test/fing/test.fing.node.ts new file mode 100644 index 0000000..72849f8 --- /dev/null +++ b/test/fing/test.fing.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FingClient, FingConfigFlow, FingIntegration, FingMapper, HomeAssistantFingIntegration, createFingDiscoveryDescriptor, fingProfile, type IFingSnapshot } from '../../ts/integrations/fing/index.js'; + +const rawData = { + device: { + id: 'fing-agent-lab', + name: 'Fing Agent Lab', + manufacturer: 'Fing', + model: 'Fing Agent', + host: '192.168.1.10', + port: 49090, + }, + entities: [ + { id: 'phone_connected', name: 'Phone Connected', platform: 'binary_sensor', state: true, attributes: { mac: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.1.21', type: 'PHONE' } }, + { id: 'printer_connected', name: 'Printer Connected', platform: 'binary_sensor', state: false, attributes: { mac: 'AA:BB:CC:DD:EE:02', ipAddress: '192.168.1.35', type: 'PRINTER' } }, + ], +}; + +tap.test('matches manual Fing candidates and creates config flow output', async () => { + const descriptor = createFingDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fing-manual-match'); + const result = await matcher!.matches({ host: '192.168.1.10', name: 'Fing Agent', metadata: { rawData, apiKey: 'local-key' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fing'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FingConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.10'); + expect(done.config?.port).toEqual(49090); + expect(done.config?.apiKey).toEqual('local-key'); +}); + +tap.test('maps Fing raw snapshots to runtime devices and entities', async () => { + const client = new FingClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FingMapper.toDevices(snapshot); + const entities = FingMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fing'); + expect(devices[0].manufacturer).toEqual('Fing'); + expect(entities.length).toEqual(2); + expect(entities[0].platform).toEqual('binary_sensor'); +}); + +tap.test('exposes Fing read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FingIntegration(); + const alias = new HomeAssistantFingIntegration(); + expect(alias.domain).toEqual('fing'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fingProfile.metadata.configFlow).toEqual(true); + expect(fingProfile.metadata.requirements).toEqual(['fing_agent_api==1.1.0']); + + const runtime = await integration.setup({ name: 'Fing Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'fing', service: 'status', target: {} }); + const snapshot = status.data as IFingSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Fing Agent Lab'); + + const command = await runtime.callService!({ domain: 'fing', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/firefly_iii/test.firefly_iii.node.ts b/test/firefly_iii/test.firefly_iii.node.ts new file mode 100644 index 0000000..5d5e6b8 --- /dev/null +++ b/test/firefly_iii/test.firefly_iii.node.ts @@ -0,0 +1,70 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FireflyIiiClient, FireflyIiiConfigFlow, FireflyIiiIntegration, FireflyIiiMapper, HomeAssistantFireflyIiiIntegration, createFireflyIiiDiscoveryDescriptor, fireflyIiiProfile, type IFireflyIiiSnapshot } from '../../ts/integrations/firefly_iii/index.js'; + +const rawData = { + device: { + id: 'firefly-local', + name: 'Firefly III Local', + manufacturer: 'Firefly III', + model: 'Firefly III', + configurationUrl: 'https://firefly.local', + }, + entities: [ + { id: 'checking_balance', name: 'Checking Balance', platform: 'sensor', state: 1234.56, unit: 'EUR', deviceClass: 'monetary' }, + { id: 'groceries_budget', name: 'Groceries Budget', platform: 'sensor', state: 210.14, unit: 'EUR', deviceClass: 'monetary' }, + ], +}; + +tap.test('matches manual Firefly III candidates and creates config flow output', async () => { + const descriptor = createFireflyIiiDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'firefly_iii-manual-match'); + const result = await matcher!.matches({ url: 'https://firefly.local', name: 'Firefly III', metadata: { rawData, apiKey: 'token' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('firefly_iii'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FireflyIiiConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('firefly.local'); + expect(done.config?.apiKey).toEqual('token'); +}); + +tap.test('maps Firefly III raw snapshots to runtime devices and entities', async () => { + const client = new FireflyIiiClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FireflyIiiMapper.toDevices(snapshot); + const entities = FireflyIiiMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('firefly_iii'); + expect(devices[0].manufacturer).toEqual('Firefly III'); + expect(entities.length).toEqual(2); + expect(entities[0].attributes?.deviceClass).toEqual('monetary'); +}); + +tap.test('exposes Firefly III read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FireflyIiiIntegration(); + const alias = new HomeAssistantFireflyIiiIntegration(); + expect(alias.domain).toEqual('firefly_iii'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fireflyIiiProfile.metadata.configFlow).toEqual(true); + expect(fireflyIiiProfile.metadata.requirements).toEqual(['pyfirefly==0.1.12']); + + const runtime = await integration.setup({ name: 'Firefly Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'firefly_iii', service: 'status', target: {} }); + const snapshot = status.data as IFireflyIiiSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Firefly III Local'); + + const command = await runtime.callService!({ domain: 'firefly_iii', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/firmata/test.firmata.node.ts b/test/firmata/test.firmata.node.ts new file mode 100644 index 0000000..f9459d4 --- /dev/null +++ b/test/firmata/test.firmata.node.ts @@ -0,0 +1,84 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FirmataClient, FirmataConfigFlow, FirmataIntegration, FirmataMapper, HomeAssistantFirmataIntegration, createFirmataDiscoveryDescriptor, firmataProfile, type IFirmataSnapshot } from '../../ts/integrations/firmata/index.js'; + +const rawData = { + device: { + id: 'firmata-usb0', + name: 'Firmata USB0', + manufacturer: 'Firmata', + model: 'Arduino Firmata', + serialNumber: '/dev/ttyUSB0', + }, + entities: [ + { id: 'analog_a0', name: 'Analog A0', platform: 'sensor', state: 512, unit: 'raw' }, + { id: 'door_input', name: 'Door Input', platform: 'binary_sensor', state: true }, + { id: 'relay_8', name: 'Relay 8', platform: 'switch', state: false, writable: true }, + { id: 'status_led', name: 'Status LED', platform: 'light', state: 128, writable: true }, + ], +}; + +tap.test('matches manual Firmata candidates and creates config flow output', async () => { + const descriptor = createFirmataDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'firmata-manual-match'); + const result = await matcher!.matches({ name: 'Firmata USB0', metadata: { rawData, serialPort: '/dev/ttyUSB0' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('firmata'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FirmataConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Firmata USB0'); + expect(done.config?.transport).toEqual('snapshot'); +}); + +tap.test('maps Firmata raw snapshots to runtime devices and entities', async () => { + const client = new FirmataClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FirmataMapper.toDevices(snapshot); + const entities = FirmataMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('firmata'); + expect(devices[0].manufacturer).toEqual('Firmata'); + expect(entities.length).toEqual(4); + expect(entities.some((entityArg) => entityArg.platform === 'switch')).toBeTrue(); +}); + +tap.test('exposes Firmata executor-gated runtime, HA alias, and unsupported control', async () => { + const integration = new FirmataIntegration(); + const alias = new HomeAssistantFirmataIntegration(); + expect(alias.domain).toEqual('firmata'); + expect(integration.status).toEqual('control-runtime'); + expect(firmataProfile.metadata.configFlow).toEqual(false); + expect(firmataProfile.metadata.requirements).toEqual(['pymata-express==1.19']); + + const runtime = await integration.setup({ name: 'Firmata Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'firmata', service: 'status', target: {} }); + const snapshot = status.data as IFirmataSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Firmata USB0'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.firmata_runtime_relay_8' } }); + expect(command.success).toBeFalse(); + await runtime.destroy(); + + const executorRuntime = await integration.setup({ + name: 'Firmata Executor', + rawData, + commandExecutor: { + execute: async (requestArg) => ({ success: true, data: { service: requestArg.service, target: requestArg.target } }), + }, + }, {}); + const executed = await executorRuntime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.firmata_executor_relay_8' } }); + expect(executed.success).toBeTrue(); + expect((executed.data as { service: string }).service).toEqual('turn_on'); + await executorRuntime.destroy(); +}); + +export default tap.start(); diff --git a/test/fivem/test.fivem.node.ts b/test/fivem/test.fivem.node.ts new file mode 100644 index 0000000..7c70a2a --- /dev/null +++ b/test/fivem/test.fivem.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FivemClient, FivemConfigFlow, FivemIntegration, FivemMapper, HomeAssistantFivemIntegration, createFivemDiscoveryDescriptor, fivemProfile, type IFivemSnapshot, type TFivemRawData } from '../../ts/integrations/fivem/index.js'; + +const rawData: TFivemRawData = { + device: { + id: 'fivem-local', + name: 'FiveM Local', + manufacturer: 'Cfx.re', + model: 'FXServer', + port: 30120, + }, + entities: [ + { id: 'status', name: 'Status', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' }, + { id: 'players_online', name: 'Players Online', platform: 'sensor', state: 12, unit: 'players', attributes: { players_list: ['Alice', 'Bob'] } }, + { id: 'players_max', name: 'Players Max', platform: 'sensor', state: 64, unit: 'players' }, + { id: 'resources', name: 'Resources', platform: 'sensor', state: 148, unit: 'resources', attributes: { resources_list: ['mapmanager', 'chat'] } }, + ], + vars: { + gamename: 'gta5', + }, +}; + +tap.test('matches manual FiveM candidates and creates config flow output', async () => { + const descriptor = createFivemDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fivem-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: 'fivem.local', port: 30120, name: 'FiveM Server', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fivem'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FivemConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('fivem.local'); + expect(done.config?.port).toEqual(30120); + expect(done.config?.path).toEqual('/dynamic.json'); +}); + +tap.test('maps FiveM raw snapshots to runtime devices and entities', async () => { + const client = new FivemClient({ name: 'FiveM Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FivemMapper.toSnapshotFromRaw({ name: 'FiveM Runtime' }, rawData); + const devices = FivemMapper.toDevices(mappedSnapshot); + const entities = FivemMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fivem'); + expect(devices[0].manufacturer).toEqual('Cfx.re'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.fivem_local_players_online')).toBeTrue(); +}); + +tap.test('exposes FiveM read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FivemIntegration(); + const alias = new HomeAssistantFivemIntegration(); + expect(alias instanceof FivemIntegration).toBeTrue(); + expect(alias.domain).toEqual('fivem'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fivemProfile.metadata.configFlow).toEqual(true); + expect(fivemProfile.metadata.requirements).toEqual(['fivem-api==0.1.2']); + + const runtime = await integration.setup({ name: 'FiveM Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'fivem', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'fivem', service: 'refresh', target: {} }); + const snapshot = status.data as IFivemSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('FiveM Local'); + + const command = await runtime.callService!({ domain: 'fivem', service: 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/fjaraskupan/test.fjaraskupan.node.ts b/test/fjaraskupan/test.fjaraskupan.node.ts new file mode 100644 index 0000000..1e9526c --- /dev/null +++ b/test/fjaraskupan/test.fjaraskupan.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FjaraskupanClient, FjaraskupanConfigFlow, FjaraskupanIntegration, FjaraskupanMapper, HomeAssistantFjaraskupanIntegration, createFjaraskupanDiscoveryDescriptor, fjaraskupanProfile, type IFjaraskupanSnapshot, type TFjaraskupanRawData } from '../../ts/integrations/fjaraskupan/index.js'; + +const rawData: TFjaraskupanRawData = { + device: { + id: 'fjaraskupan-aa-bb', + name: 'Fjaraskupan Kitchen', + manufacturer: 'Fjaraskupan', + model: 'Bluetooth cooker hood', + serialNumber: 'AA:BB:CC:DD:EE:FF', + }, + entities: [ + { id: 'fan', name: 'Fan', platform: 'fan', state: 50, writable: true, attributes: { presetMode: 'normal', speedCount: 8 } }, + { id: 'light', name: 'Light', platform: 'light', state: true, writable: true, attributes: { brightness: 191 } }, + { id: 'periodic_venting', name: 'Periodic Venting', platform: 'number', state: 15, writable: true, unit: 'min', attributes: { min: 0, max: 59, step: 1 } }, + { id: 'rssi', name: 'Signal Strength', platform: 'sensor', state: -62, unit: 'dBm', deviceClass: 'signal_strength' }, + { id: 'grease_filter', name: 'Grease Filter', platform: 'binary_sensor', state: false, deviceClass: 'problem' }, + { id: 'carbon_filter', name: 'Carbon Filter', platform: 'binary_sensor', state: true, deviceClass: 'problem' }, + ], + state: { + fan_speed: 4, + light_on: true, + periodic_venting: 15, + }, +}; + +tap.test('matches manual Fjaraskupan candidates and creates config flow output', async () => { + const descriptor = createFjaraskupanDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fjaraskupan-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Fjaraskupan Kitchen', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fjaraskupan'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FjaraskupanConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Fjaraskupan Kitchen'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Fjaraskupan raw snapshots to runtime devices and entities', async () => { + const client = new FjaraskupanClient({ name: 'Fjaraskupan Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FjaraskupanMapper.toSnapshotFromRaw({ name: 'Fjaraskupan Runtime' }, rawData); + const devices = FjaraskupanMapper.toDevices(mappedSnapshot); + const entities = FjaraskupanMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fjaraskupan'); + expect(devices[0].manufacturer).toEqual('Fjaraskupan'); + expect(entities.some((entityArg) => entityArg.id === 'fan.fjaraskupan_kitchen_fan')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.fjaraskupan_kitchen_carbon_filter')).toBeTrue(); +}); + +tap.test('exposes Fjaraskupan read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FjaraskupanIntegration(); + const alias = new HomeAssistantFjaraskupanIntegration(); + expect(alias instanceof FjaraskupanIntegration).toBeTrue(); + expect(alias.domain).toEqual('fjaraskupan'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fjaraskupanProfile.metadata.configFlow).toEqual(true); + expect(fjaraskupanProfile.metadata.requirements).toEqual(['fjaraskupan==2.3.3']); + expect(fjaraskupanProfile.metadata.dependencies).toEqual(['bluetooth_adapters']); + + const runtime = await integration.setup({ name: 'Fjaraskupan Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'fjaraskupan', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'fjaraskupan', service: 'refresh', target: {} }); + const snapshot = status.data as IFjaraskupanSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Fjaraskupan Kitchen'); + + const command = await runtime.callService!({ domain: 'fan', service: 'turn_on', target: { entityId: 'fan.fjaraskupan_runtime_fan' }, data: { percentage: 50 } }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/flexit/test.flexit.node.ts b/test/flexit/test.flexit.node.ts new file mode 100644 index 0000000..a19d9f6 --- /dev/null +++ b/test/flexit/test.flexit.node.ts @@ -0,0 +1,97 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FlexitClient, FlexitConfigFlow, FlexitIntegration, FlexitMapper, HomeAssistantFlexitIntegration, createFlexitDiscoveryDescriptor, flexitProfile, type IFlexitSnapshot, type TFlexitRawData } from '../../ts/integrations/flexit/index.js'; + +const rawData: TFlexitRawData = { + device: { + id: 'flexit-ci66', + name: 'Flexit CI66', + manufacturer: 'Flexit', + model: 'CI66 Modbus adapter', + }, + entities: [ + { + id: 'climate', + name: 'Climate', + platform: 'climate', + state: 'cool', + writable: true, + attributes: { + currentTemperature: 21.1, + targetTemperature: 19.5, + fanMode: 'Medium', + fanModes: ['Off', 'Low', 'Medium', 'High'], + hvacAction: 'fan', + hvacModes: ['cool'], + }, + }, + { id: 'filter_hours', name: 'Filter Hours', platform: 'sensor', state: 124, unit: 'h' }, + { id: 'filter_alarm', name: 'Filter Alarm', platform: 'binary_sensor', state: false, deviceClass: 'problem' }, + { id: 'heat_recovery', name: 'Heat Recovery', platform: 'sensor', state: 35, unit: '%' }, + { id: 'outdoor_air_temp', name: 'Outdoor Air Temp', platform: 'sensor', state: 8.4, unit: 'C', deviceClass: 'temperature' }, + ], + registers: { + holding8TargetTemperature: 195, + holding17FanMode: 2, + input9CurrentTemperature: 211, + }, +}; + +tap.test('matches manual Flexit candidates and creates config flow output', async () => { + const descriptor = createFlexitDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flexit-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'flexit-ci66', name: 'Flexit CI66', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('flexit'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FlexitConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Flexit CI66'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Flexit raw snapshots to runtime devices and entities', async () => { + const client = new FlexitClient({ name: 'Flexit Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FlexitMapper.toSnapshotFromRaw({ name: 'Flexit Runtime' }, rawData); + const devices = FlexitMapper.toDevices(mappedSnapshot); + const entities = FlexitMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('flexit'); + expect(devices[0].manufacturer).toEqual('Flexit'); + expect(entities.some((entityArg) => entityArg.id === 'climate.flexit_ci66_climate')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.flexit_ci66_filter_alarm')).toBeTrue(); +}); + +tap.test('exposes Flexit read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FlexitIntegration(); + const alias = new HomeAssistantFlexitIntegration(); + expect(alias instanceof FlexitIntegration).toBeTrue(); + expect(alias.domain).toEqual('flexit'); + expect(integration.status).toEqual('read-only-runtime'); + expect(flexitProfile.metadata.configFlow).toEqual(false); + expect(flexitProfile.metadata.qualityScale).toEqual('legacy'); + expect(flexitProfile.metadata.dependencies).toEqual(['modbus']); + + const runtime = await integration.setup({ name: 'Flexit Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'flexit', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'flexit', service: 'refresh', target: {} }); + const snapshot = status.data as IFlexitSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Flexit CI66'); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.flexit_runtime_climate' }, data: { temperature: 20 } }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index a13def4..ad52483 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -131,7 +131,37 @@ import { Elkm1Integration } from './integrations/elkm1/index.js'; import { ElvIntegration } from './integrations/elv/index.js'; import { EmbyIntegration } from './integrations/emby/index.js'; import { EmoncmsIntegration } from './integrations/emoncms/index.js'; +import { EmoncmsHistoryIntegration } from './integrations/emoncms_history/index.js'; +import { EmonitorIntegration } from './integrations/emonitor/index.js'; +import { EmulatedHueIntegration } from './integrations/emulated_hue/index.js'; +import { EmulatedKasaIntegration } from './integrations/emulated_kasa/index.js'; +import { EmulatedRokuIntegration } from './integrations/emulated_roku/index.js'; +import { EnergeniePowerSocketsIntegration } from './integrations/energenie_power_sockets/index.js'; +import { Enigma2Integration } from './integrations/enigma2/index.js'; +import { EnoceanIntegration } from './integrations/enocean/index.js'; +import { EnphaseEnvoyIntegration } from './integrations/enphase_envoy/index.js'; +import { EnvisalinkIntegration } from './integrations/envisalink/index.js'; +import { EphemberIntegration } from './integrations/ephember/index.js'; +import { EpsonIntegration } from './integrations/epson/index.js'; +import { Eq3btsmartIntegration } from './integrations/eq3btsmart/index.js'; +import { EsceaIntegration } from './integrations/escea/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js'; +import { EufyIntegration } from './integrations/eufy/index.js'; +import { EufylifeBleIntegration } from './integrations/eufylife_ble/index.js'; +import { EurotronicCometblueIntegration } from './integrations/eurotronic_cometblue/index.js'; +import { EverlightsIntegration } from './integrations/everlights/index.js'; +import { EvilGeniusLabsIntegration } from './integrations/evil_genius_labs/index.js'; +import { Fail2banIntegration } from './integrations/fail2ban/index.js'; +import { FamilyhubIntegration } from './integrations/familyhub/index.js'; +import { FibaroIntegration } from './integrations/fibaro/index.js'; +import { FileIntegration } from './integrations/file/index.js'; +import { FilesizeIntegration } from './integrations/filesize/index.js'; +import { FingIntegration } from './integrations/fing/index.js'; +import { FireflyIiiIntegration } from './integrations/firefly_iii/index.js'; +import { FirmataIntegration } from './integrations/firmata/index.js'; +import { FivemIntegration } from './integrations/fivem/index.js'; +import { FjaraskupanIntegration } from './integrations/fjaraskupan/index.js'; +import { FlexitIntegration } from './integrations/flexit/index.js'; import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js'; import { FoscamIntegration } from './integrations/foscam/index.js'; import { FreeboxIntegration } from './integrations/freebox/index.js'; @@ -362,7 +392,37 @@ export const integrations = [ new ElvIntegration(), new EmbyIntegration(), new EmoncmsIntegration(), + new EmoncmsHistoryIntegration(), + new EmonitorIntegration(), + new EmulatedHueIntegration(), + new EmulatedKasaIntegration(), + new EmulatedRokuIntegration(), + new EnergeniePowerSocketsIntegration(), + new Enigma2Integration(), + new EnoceanIntegration(), + new EnphaseEnvoyIntegration(), + new EnvisalinkIntegration(), + new EphemberIntegration(), + new EpsonIntegration(), + new Eq3btsmartIntegration(), + new EsceaIntegration(), new EsphomeIntegration(), + new EufyIntegration(), + new EufylifeBleIntegration(), + new EurotronicCometblueIntegration(), + new EverlightsIntegration(), + new EvilGeniusLabsIntegration(), + new Fail2banIntegration(), + new FamilyhubIntegration(), + new FibaroIntegration(), + new FileIntegration(), + new FilesizeIntegration(), + new FingIntegration(), + new FireflyIiiIntegration(), + new FirmataIntegration(), + new FivemIntegration(), + new FjaraskupanIntegration(), + new FlexitIntegration(), new ForkedDaapdIntegration(), new FoscamIntegration(), new FreeboxIntegration(), diff --git a/ts/integrations/emoncms_history/.generated-by-smarthome-exchange b/ts/integrations/emoncms_history/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/emoncms_history/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/emoncms_history/emoncms_history.classes.client.ts b/ts/integrations/emoncms_history/emoncms_history.classes.client.ts new file mode 100644 index 0000000..8474e66 --- /dev/null +++ b/ts/integrations/emoncms_history/emoncms_history.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EmoncmsHistoryMapper } from './emoncms_history.mapper.js'; +import type { IEmoncmsHistoryConfig, IEmoncmsHistorySnapshot } from './emoncms_history.types.js'; +import { emoncmsHistoryProfile } from './emoncms_history.types.js'; + +export class EmoncmsHistoryClient extends SimpleLocalClient { + constructor(private readonly configArg: IEmoncmsHistoryConfig) { + super(emoncmsHistoryProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EmoncmsHistoryMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EmoncmsHistoryMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/emoncms_history/emoncms_history.classes.configflow.ts b/ts/integrations/emoncms_history/emoncms_history.classes.configflow.ts new file mode 100644 index 0000000..722109e --- /dev/null +++ b/ts/integrations/emoncms_history/emoncms_history.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js'; +import { emoncmsHistoryProfile } from './emoncms_history.types.js'; + +export class EmoncmsHistoryConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(emoncmsHistoryProfile); + } +} diff --git a/ts/integrations/emoncms_history/emoncms_history.classes.integration.ts b/ts/integrations/emoncms_history/emoncms_history.classes.integration.ts index a1a7488..9f7c9bb 100644 --- a/ts/integrations/emoncms_history/emoncms_history.classes.integration.ts +++ b/ts/integrations/emoncms_history/emoncms_history.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EmoncmsHistoryClient } from './emoncms_history.classes.client.js'; +import { EmoncmsHistoryConfigFlow } from './emoncms_history.classes.configflow.js'; +import { createEmoncmsHistoryDiscoveryDescriptor } from './emoncms_history.discovery.js'; +import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js'; +import { emoncmsHistoryDomain, emoncmsHistoryProfile } from './emoncms_history.types.js'; + +export class EmoncmsHistoryIntegration extends SimpleLocalIntegration { + public readonly domain = emoncmsHistoryDomain; + public readonly discoveryDescriptor = createEmoncmsHistoryDiscoveryDescriptor(); + public readonly configFlow = new EmoncmsHistoryConfigFlow(); -export class HomeAssistantEmoncmsHistoryIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "emoncms_history", - displayName: "Emoncms History", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/emoncms_history", - "upstreamDomain": "emoncms_history", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "pyemoncms==0.1.3" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@alexandrecuer" - ] -}, - }); + super(emoncmsHistoryProfile); + } + + public async setup(configArg: IEmoncmsHistoryConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(emoncmsHistoryProfile, new EmoncmsHistoryClient(configArg)); } } + +export class HomeAssistantEmoncmsHistoryIntegration extends EmoncmsHistoryIntegration {} diff --git a/ts/integrations/emoncms_history/emoncms_history.discovery.ts b/ts/integrations/emoncms_history/emoncms_history.discovery.ts new file mode 100644 index 0000000..cc82a17 --- /dev/null +++ b/ts/integrations/emoncms_history/emoncms_history.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { emoncmsHistoryProfile } from './emoncms_history.types.js'; + +export const createEmoncmsHistoryDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emoncmsHistoryProfile); diff --git a/ts/integrations/emoncms_history/emoncms_history.mapper.ts b/ts/integrations/emoncms_history/emoncms_history.mapper.ts new file mode 100644 index 0000000..7bdbdaa --- /dev/null +++ b/ts/integrations/emoncms_history/emoncms_history.mapper.ts @@ -0,0 +1,122 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js'; +import { emoncmsHistoryDefaultName, emoncmsHistoryProfile } from './emoncms_history.types.js'; + +export class EmoncmsHistoryMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: emoncmsHistoryProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEmoncmsHistoryConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(emoncmsHistoryProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(emoncmsHistoryProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEmoncmsHistoryConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) { + return rawDataArg; + } + + const payload = recordValue(rawDataArg.payload_dict) || recordValue(rawDataArg.payload) || recordValue(rawDataArg.data) || recordValue(rawDataArg.values) || recordValue(rawDataArg.states) || primitiveRecord(rawDataArg); + const readings = Object.entries(payload).filter((entryArg): entryArg is [string, string | number | boolean | null] => isPrimitive(entryArg[1])); + if (!readings.length) { + return rawDataArg; + } + + const endpoint = endpointInfo(configArg, rawDataArg); + const inputNode = configArg.inputNode ?? configArg.inputnode ?? rawDataArg.inputnode ?? rawDataArg.inputNode ?? rawDataArg.node; + const whitelist = stringArray(configArg.whitelist) || stringArray(rawDataArg.whitelist); + const units = recordValue(rawDataArg.units) || {}; + const name = configArg.name || stringValue(rawDataArg.name) || emoncmsHistoryDefaultName; + const entities: ISimpleLocalEntitySnapshot[] = readings.map(([keyArg, valueArg]) => ({ + id: SimpleLocalMapper.slug(keyArg), + uniqueId: `${emoncmsHistoryProfile.domain}_${SimpleLocalMapper.slug(endpoint.host || name)}_${SimpleLocalMapper.slug(keyArg)}`, + name: titleCase(keyArg), + platform: 'sensor', + state: valueArg, + available: true, + writable: false, + unit: stringValue(units[keyArg]), + attributes: { + entityId: keyArg, + inputNode, + whitelisted: whitelist ? whitelist.includes(keyArg) : undefined, + }, + })); + + return { + device: { + id: configArg.uniqueId || (endpoint.host ? `${endpoint.host}:${endpoint.port || ''}` : undefined) || stringValue(inputNode) || name, + name, + manufacturer: emoncmsHistoryProfile.manufacturer, + model: emoncmsHistoryProfile.model, + host: endpoint.host, + port: endpoint.port, + protocol: endpoint.useTls ? 'https' : emoncmsHistoryProfile.defaultProtocol, + configurationUrl: endpoint.url, + attributes: { + inputNode, + scanInterval: configArg.scanInterval ?? configArg.scan_interval ?? rawDataArg.scan_interval ?? rawDataArg.scanInterval, + whitelist, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const ignoredPayloadKeys = new Set(['api_key', 'apiKey', 'client', 'commandExecutor', 'device', 'host', 'inputnode', 'inputNode', 'metadata', 'node', 'password', 'scan_interval', 'scanInterval', 'snapshot', 'snapshotProvider', 'token', 'units', 'url', 'username', 'whitelist']); + +const primitiveRecord = (valueArg: Record): Record => Object.fromEntries(Object.entries(valueArg).filter(([keyArg, rawValueArg]) => !ignoredPayloadKeys.has(keyArg) && isPrimitive(rawValueArg))); + +const endpointInfo = (configArg: IEmoncmsHistoryConfig, rawDataArg: Record): { host?: string; port?: number; useTls?: boolean; url?: string } => { + const urlValue = configArg.url || stringValue(rawDataArg.url); + const parsed = parseUrl(urlValue); + return { + host: configArg.host || parsed?.host, + port: configArg.port || parsed?.port, + useTls: configArg.useTls ?? parsed?.useTls, + url: parsed?.url || urlValue, + }; +}; + +const parseUrl = (valueArg?: string): { host: string; port?: number; useTls: boolean; url: string } | undefined => { + if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) { + return undefined; + } + try { + const url = new URL(valueArg); + return { host: url.hostname, port: url.port ? Number(url.port) : undefined, useTls: url.protocol === 'https:', url: url.toString() }; + } catch { + return undefined; + } +}; + +const hasSimpleEntities = (valueArg: Record): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg); +const isSnapshotLike = (valueArg: Record): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities); +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); +const isPrimitive = (valueArg: unknown): valueArg is string | number | boolean | null => valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg); +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined; +const titleCase = (valueArg: string): string => valueArg.replace(/[_./-]+/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); diff --git a/ts/integrations/emoncms_history/emoncms_history.types.ts b/ts/integrations/emoncms_history/emoncms_history.types.ts index 090e892..f96685a 100644 --- a/ts/integrations/emoncms_history/emoncms_history.types.ts +++ b/ts/integrations/emoncms_history/emoncms_history.types.ts @@ -1,4 +1,93 @@ -export interface IHomeAssistantEmoncmsHistoryConfig { - // TODO: replace with the TypeScript-native config for emoncms_history. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const emoncmsHistoryDomain = 'emoncms_history'; +export const emoncmsHistoryDefaultName = 'Emoncms History'; + +export type TEmoncmsHistoryRawData = TSimpleLocalRawData; +export interface IEmoncmsHistorySnapshot extends ISimpleLocalSnapshot {} +export interface IEmoncmsHistoryConfig extends ISimpleLocalConfig { + url?: string; + inputNode?: string | number; + inputnode?: string | number; + whitelist?: string[]; + scanInterval?: number; + scan_interval?: number; } +export interface IHomeAssistantEmoncmsHistoryConfig extends IEmoncmsHistoryConfig {} + +const emoncmsHistoryControlServices = [ + 'input_post', + 'send', + 'send_history', +]; + +export const emoncmsHistoryProfile: ISimpleLocalIntegrationProfile = { + domain: 'emoncms_history', + displayName: 'Emoncms History', + manufacturer: 'OpenEnergyMonitor', + model: 'Emoncms History', + defaultName: emoncmsHistoryDefaultName, + defaultHttpPath: '/input/post.json', + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [ + 'emoncms_history', + ], + controlServices: emoncmsHistoryControlServices, + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'emoncms', + 'emoncms history', + 'openenergymonitor', + 'inputnode', + 'history export', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/emoncms_history', + upstreamDomain: 'emoncms_history', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [ + 'pyemoncms==0.1.3', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@alexandrecuer', + ], + configFlow: false, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...emoncmsHistoryControlServices, + ], + platforms: [ + 'sensor', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local Emoncms URL or host setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'mapping configured or raw entity values into outbound history payload snapshots', + 'executor-gated Emoncms history/input posting through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'claiming input_post, send, or send_history success without injected client.execute or commandExecutor', + 'collecting live Home Assistant state from a Home Assistant state machine', + 'performing pyemoncms writes without an injected native client or commandExecutor', + ], + }, + }, +}; diff --git a/ts/integrations/emoncms_history/index.ts b/ts/integrations/emoncms_history/index.ts index aa219a3..f909b3a 100644 --- a/ts/integrations/emoncms_history/index.ts +++ b/ts/integrations/emoncms_history/index.ts @@ -1,2 +1,6 @@ +export * from './emoncms_history.classes.client.js'; +export * from './emoncms_history.classes.configflow.js'; export * from './emoncms_history.classes.integration.js'; +export * from './emoncms_history.discovery.js'; +export * from './emoncms_history.mapper.js'; export * from './emoncms_history.types.js'; diff --git a/ts/integrations/emonitor/.generated-by-smarthome-exchange b/ts/integrations/emonitor/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/emonitor/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/emonitor/emonitor.classes.client.ts b/ts/integrations/emonitor/emonitor.classes.client.ts new file mode 100644 index 0000000..923b18f --- /dev/null +++ b/ts/integrations/emonitor/emonitor.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EmonitorMapper } from './emonitor.mapper.js'; +import type { IEmonitorConfig, IEmonitorSnapshot } from './emonitor.types.js'; +import { emonitorProfile } from './emonitor.types.js'; + +export class EmonitorClient extends SimpleLocalClient { + constructor(private readonly configArg: IEmonitorConfig) { + super(emonitorProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EmonitorMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EmonitorMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/emonitor/emonitor.classes.configflow.ts b/ts/integrations/emonitor/emonitor.classes.configflow.ts new file mode 100644 index 0000000..dc6de14 --- /dev/null +++ b/ts/integrations/emonitor/emonitor.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEmonitorConfig } from './emonitor.types.js'; +import { emonitorProfile } from './emonitor.types.js'; + +export class EmonitorConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(emonitorProfile); + } +} diff --git a/ts/integrations/emonitor/emonitor.classes.integration.ts b/ts/integrations/emonitor/emonitor.classes.integration.ts index ac4df3f..9b52360 100644 --- a/ts/integrations/emonitor/emonitor.classes.integration.ts +++ b/ts/integrations/emonitor/emonitor.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EmonitorClient } from './emonitor.classes.client.js'; +import { EmonitorConfigFlow } from './emonitor.classes.configflow.js'; +import { createEmonitorDiscoveryDescriptor } from './emonitor.discovery.js'; +import type { IEmonitorConfig } from './emonitor.types.js'; +import { emonitorDomain, emonitorProfile } from './emonitor.types.js'; + +export class EmonitorIntegration extends SimpleLocalIntegration { + public readonly domain = emonitorDomain; + public readonly discoveryDescriptor = createEmonitorDiscoveryDescriptor(); + public readonly configFlow = new EmonitorConfigFlow(); -export class HomeAssistantEmonitorIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "emonitor", - displayName: "SiteSage Emonitor", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/emonitor", - "upstreamDomain": "emonitor", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "aioemonitor==1.0.5" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@bdraco" - ] -}, - }); + super(emonitorProfile); + } + + public async setup(configArg: IEmonitorConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(emonitorProfile, new EmonitorClient(configArg)); } } + +export class HomeAssistantEmonitorIntegration extends EmonitorIntegration {} diff --git a/ts/integrations/emonitor/emonitor.discovery.ts b/ts/integrations/emonitor/emonitor.discovery.ts new file mode 100644 index 0000000..629aeb5 --- /dev/null +++ b/ts/integrations/emonitor/emonitor.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { emonitorProfile } from './emonitor.types.js'; + +export const createEmonitorDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emonitorProfile); diff --git a/ts/integrations/emonitor/emonitor.mapper.ts b/ts/integrations/emonitor/emonitor.mapper.ts new file mode 100644 index 0000000..3b70db9 --- /dev/null +++ b/ts/integrations/emonitor/emonitor.mapper.ts @@ -0,0 +1,134 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEmonitorConfig } from './emonitor.types.js'; +import { emonitorDefaultName, emonitorProfile } from './emonitor.types.js'; + +export class EmonitorMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: emonitorProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEmonitorConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(emonitorProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(emonitorProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEmonitorConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) { + return rawDataArg; + } + + const channels = channelRecords(rawDataArg.channels); + if (!channels.length) { + return rawDataArg; + } + + const network = recordValue(rawDataArg.network) || {}; + const hardware = recordValue(rawDataArg.hardware) || {}; + const macAddress = configArg.macAddress || configArg.mac_address || stringValue(network.mac_address) || stringValue(network.macAddress) || stringValue(rawDataArg.mac_address) || stringValue(rawDataArg.macAddress); + const serialNumber = stringValue(hardware.serial_number) || stringValue(hardware.serialNumber) || stringValue(rawDataArg.serial_number) || stringValue(rawDataArg.serialNumber); + const firmwareVersion = stringValue(hardware.firmware_version) || stringValue(hardware.firmwareVersion) || stringValue(rawDataArg.firmware_version) || stringValue(rawDataArg.firmwareVersion); + const deviceName = configArg.name || stringValue(rawDataArg.name) || (macAddress ? `Emonitor ${shortMac(macAddress)}` : emonitorDefaultName); + const entities: ISimpleLocalEntitySnapshot[] = []; + const seenChannels = new Set(); + + for (const channel of channels) { + seenChannels.add(channel.number); + if (channel.data.active === false) { + continue; + } + const pairedChannel = stringValue(channel.data.paired_with_channel) || stringValue(channel.data.pairedWithChannel) || numberString(channel.data.paired_with_channel) || numberString(channel.data.pairedWithChannel); + if (pairedChannel && seenChannels.has(pairedChannel)) { + continue; + } + + const label = stringValue(channel.data.label) || channel.number; + entities.push(...powerEntities({ channelNumber: channel.number, label, data: channel.data, pairedData: pairedChannel ? channels.find((candidateArg) => candidateArg.number === pairedChannel)?.data : undefined, macAddress, deviceName })); + } + + return { + device: { + id: configArg.uniqueId || macAddress || serialNumber || configArg.host || deviceName, + name: deviceName, + manufacturer: emonitorProfile.manufacturer, + model: emonitorProfile.model, + serialNumber, + host: configArg.host, + port: configArg.port, + protocol: emonitorProfile.defaultProtocol, + attributes: { + firmwareVersion, + macAddress, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const powerEntities = (optionsArg: { channelNumber: string; label: string; data: Record; pairedData?: Record; macAddress?: string; deviceName: string }): ISimpleLocalEntitySnapshot[] => { + const base = SimpleLocalMapper.slug(optionsArg.macAddress || optionsArg.deviceName); + return [ + ['inst_power', optionsArg.label], + ['avg_power', `${optionsArg.label} average`], + ['max_power', `${optionsArg.label} max`], + ].map(([keyArg, nameArg]) => ({ + id: `channel_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`, + uniqueId: `${emonitorProfile.domain}_${base}_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`, + name: nameArg, + platform: 'sensor', + state: sumPower(optionsArg.data, optionsArg.pairedData, keyArg), + available: true, + writable: false, + unit: 'W', + deviceClass: 'power', + stateClass: 'measurement', + attributes: { + channel: Number(optionsArg.channelNumber), + pairedWithChannel: numberValue(optionsArg.data.paired_with_channel) ?? numberValue(optionsArg.data.pairedWithChannel), + }, + })); +}; + +const sumPower = (channelArg: Record, pairedChannelArg: Record | undefined, keyArg: string): number => (numberValue(channelArg[keyArg]) || 0) + (pairedChannelArg ? numberValue(pairedChannelArg[keyArg]) || 0 : 0); + +const channelRecords = (valueArg: unknown): Array<{ number: string; data: Record }> => { + if (Array.isArray(valueArg)) { + return valueArg.filter(isRecord).map((channelArg, indexArg) => ({ number: stringValue(channelArg.channel) || stringValue(channelArg.channel_number) || String(indexArg + 1), data: channelArg })); + } + if (isRecord(valueArg)) { + return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record] => isRecord(entryArg[1])).map(([numberArg, dataArg]) => ({ number: numberArg, data: dataArg })); + } + return []; +}; + +const shortMac = (valueArg: string): string => valueArg.replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase(); +const hasSimpleEntities = (valueArg: Record): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg); +const isSnapshotLike = (valueArg: Record): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities); +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const numberString = (valueArg: unknown): string | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? String(valueArg) : undefined; +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; diff --git a/ts/integrations/emonitor/emonitor.types.ts b/ts/integrations/emonitor/emonitor.types.ts index 1c1f453..3992c1c 100644 --- a/ts/integrations/emonitor/emonitor.types.ts +++ b/ts/integrations/emonitor/emonitor.types.ts @@ -1,4 +1,89 @@ -export interface IHomeAssistantEmonitorConfig { - // TODO: replace with the TypeScript-native config for emonitor. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const emonitorDomain = 'emonitor'; +export const emonitorDefaultName = 'SiteSage Emonitor'; + +export type TEmonitorRawData = TSimpleLocalRawData; +export interface IEmonitorSnapshot extends ISimpleLocalSnapshot {} +export interface IEmonitorConfig extends ISimpleLocalConfig { + macAddress?: string; + mac_address?: string; } +export interface IHomeAssistantEmonitorConfig extends IEmonitorConfig {} + +export const emonitorProfile: ISimpleLocalIntegrationProfile = { + domain: 'emonitor', + displayName: 'SiteSage Emonitor', + manufacturer: 'Powerhouse Dynamics, Inc.', + model: 'SiteSage Emonitor', + defaultName: emonitorDefaultName, + defaultProtocol: 'http', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'dhcp', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'emonitor', + 'sitesage', + 'powerhouse dynamics', + '0090c2', + 'power monitor', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/emonitor', + upstreamDomain: 'emonitor', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'aioemonitor==1.0.5', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@bdraco', + ], + configFlow: true, + dhcp: [ + { + hostname: 'emonitor*', + macaddress: '0090C2*', + }, + { + registered_devices: true, + }, + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local host setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'SiteSage Emonitor status/channel snapshot mapping compatible with aioemonitor status data', + ], + explicitUnsupported: [ + 'claiming live control success without injected client.execute or commandExecutor', + 'guessing undocumented aioemonitor HTTP endpoints for direct polling', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/emonitor/index.ts b/ts/integrations/emonitor/index.ts index b53244b..0f52381 100644 --- a/ts/integrations/emonitor/index.ts +++ b/ts/integrations/emonitor/index.ts @@ -1,2 +1,6 @@ +export * from './emonitor.classes.client.js'; +export * from './emonitor.classes.configflow.js'; export * from './emonitor.classes.integration.js'; +export * from './emonitor.discovery.js'; +export * from './emonitor.mapper.js'; export * from './emonitor.types.js'; diff --git a/ts/integrations/emulated_hue/.generated-by-smarthome-exchange b/ts/integrations/emulated_hue/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/emulated_hue/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/emulated_hue/emulated_hue.classes.client.ts b/ts/integrations/emulated_hue/emulated_hue.classes.client.ts new file mode 100644 index 0000000..a309671 --- /dev/null +++ b/ts/integrations/emulated_hue/emulated_hue.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EmulatedHueMapper } from './emulated_hue.mapper.js'; +import type { IEmulatedHueConfig, IEmulatedHueSnapshot } from './emulated_hue.types.js'; +import { emulatedHueProfile } from './emulated_hue.types.js'; + +export class EmulatedHueClient extends SimpleLocalClient { + constructor(private readonly configArg: IEmulatedHueConfig) { + super(emulatedHueProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EmulatedHueMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EmulatedHueMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/emulated_hue/emulated_hue.classes.configflow.ts b/ts/integrations/emulated_hue/emulated_hue.classes.configflow.ts new file mode 100644 index 0000000..6545cf1 --- /dev/null +++ b/ts/integrations/emulated_hue/emulated_hue.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEmulatedHueConfig } from './emulated_hue.types.js'; +import { emulatedHueProfile } from './emulated_hue.types.js'; + +export class EmulatedHueConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(emulatedHueProfile); + } +} diff --git a/ts/integrations/emulated_hue/emulated_hue.classes.integration.ts b/ts/integrations/emulated_hue/emulated_hue.classes.integration.ts index b38f829..c22f2dd 100644 --- a/ts/integrations/emulated_hue/emulated_hue.classes.integration.ts +++ b/ts/integrations/emulated_hue/emulated_hue.classes.integration.ts @@ -1,29 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EmulatedHueClient } from './emulated_hue.classes.client.js'; +import { EmulatedHueConfigFlow } from './emulated_hue.classes.configflow.js'; +import { createEmulatedHueDiscoveryDescriptor } from './emulated_hue.discovery.js'; +import type { IEmulatedHueConfig } from './emulated_hue.types.js'; +import { emulatedHueDomain, emulatedHueProfile } from './emulated_hue.types.js'; + +export class EmulatedHueIntegration extends SimpleLocalIntegration { + public readonly domain = emulatedHueDomain; + public readonly discoveryDescriptor = createEmulatedHueDiscoveryDescriptor(); + public readonly configFlow = new EmulatedHueConfigFlow(); -export class HomeAssistantEmulatedHueIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "emulated_hue", - displayName: "Emulated Hue", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/emulated_hue", - "upstreamDomain": "emulated_hue", - "iotClass": "local_push", - "qualityScale": "internal", - "requirements": [], - "dependencies": [ - "network" - ], - "afterDependencies": [ - "http" - ], - "codeowners": [ - "@bdraco", - "@Tho85" - ] -}, - }); + super(emulatedHueProfile); + } + + public async setup(configArg: IEmulatedHueConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(emulatedHueProfile, new EmulatedHueClient(configArg)); } } + +export class HomeAssistantEmulatedHueIntegration extends EmulatedHueIntegration {} diff --git a/ts/integrations/emulated_hue/emulated_hue.discovery.ts b/ts/integrations/emulated_hue/emulated_hue.discovery.ts new file mode 100644 index 0000000..72b81e4 --- /dev/null +++ b/ts/integrations/emulated_hue/emulated_hue.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { emulatedHueProfile } from './emulated_hue.types.js'; + +export const createEmulatedHueDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedHueProfile); diff --git a/ts/integrations/emulated_hue/emulated_hue.mapper.ts b/ts/integrations/emulated_hue/emulated_hue.mapper.ts new file mode 100644 index 0000000..45287d3 --- /dev/null +++ b/ts/integrations/emulated_hue/emulated_hue.mapper.ts @@ -0,0 +1,206 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEmulatedHueConfig } from './emulated_hue.types.js'; +import { emulatedHueDefaultName, emulatedHueDefaultPort, emulatedHueProfile, emulatedHueSerialNumber, emulatedHueUuid } from './emulated_hue.types.js'; + +export class EmulatedHueMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: emulatedHueProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEmulatedHueConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(emulatedHueProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(emulatedHueProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEmulatedHueConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) { + return rawDataArg; + } + + const hueLights = hueLightEntities(rawDataArg.lights); + const haStates = stateRecords(rawDataArg.states ?? rawDataArg.hassStates ?? rawDataArg.entities); + const entities = haStates.length ? entitiesFromStates(configArg, rawDataArg, haStates) : hueLights; + if (!entities.length) { + return rawDataArg; + } + + const config = recordValue(rawDataArg.config) || {}; + const endpoint = endpointInfo(configArg, rawDataArg, config); + const name = configArg.name || stringValue(config.name) || stringValue(rawDataArg.name) || 'HASS BRIDGE'; + + return { + device: { + id: configArg.uniqueId || stringValue(config.mac) || emulatedHueSerialNumber, + name, + manufacturer: emulatedHueProfile.manufacturer, + model: emulatedHueProfile.model, + serialNumber: emulatedHueSerialNumber, + host: endpoint.host, + port: endpoint.port, + protocol: emulatedHueProfile.defaultProtocol, + configurationUrl: endpoint.host ? `http://${endpoint.host}:${endpoint.port || emulatedHueDefaultPort}` : undefined, + attributes: { + uuid: emulatedHueUuid, + type: configArg.type || stringValue(rawDataArg.type) || 'google_home', + exposeByDefault: configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault, + lightsAllDimmable: configArg.lightsAllDimmable ?? configArg.lights_all_dimmable ?? rawDataArg.lights_all_dimmable ?? rawDataArg.lightsAllDimmable, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const defaultExposedDomains = ['switch', 'light', 'group', 'input_boolean', 'media_player', 'fan']; +const platformByDomain: Record = { + climate: 'climate', + cover: 'cover', + fan: 'fan', + group: 'switch', + humidifier: 'fan', + input_boolean: 'switch', + light: 'light', + media_player: 'media_player', + scene: 'button', + script: 'button', + switch: 'switch', +}; +const offStates = new Set(['closed', 'off', 'unavailable', 'unknown']); + +const entitiesFromStates = (configArg: IEmulatedHueConfig, rawDataArg: Record, stateRecordsArg: Array>): ISimpleLocalEntitySnapshot[] => { + const exposedDomains = stringArray(configArg.exposedDomains) || stringArray(configArg.exposed_domains) || stringArray(rawDataArg.exposed_domains) || stringArray(rawDataArg.exposedDomains) || defaultExposedDomains; + const exposeByDefault = configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault ?? true; + return stateRecordsArg.map((stateArg, indexArg) => entityFromState(configArg, exposedDomains, Boolean(exposeByDefault), stateArg, indexArg)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg)); +}; + +const entityFromState = (configArg: IEmulatedHueConfig, exposedDomainsArg: string[], exposeByDefaultArg: boolean, stateArg: Record, indexArg: number): ISimpleLocalEntitySnapshot | undefined => { + const entityId = stringValue(stateArg.entity_id) || stringValue(stateArg.entityId) || stringValue(stateArg.id); + if (!entityId) { + return undefined; + } + const domain = entityId.split('.')[0]; + const platform = platformByDomain[domain]; + if (!platform || (exposeByDefaultArg && !exposedDomainsArg.includes(domain)) || (!exposeByDefaultArg && !configArg.exposedEntities?.[entityId])) { + return undefined; + } + + const attributes = recordValue(stateArg.attributes) || {}; + const exposedEntityConfig = configArg.exposedEntities?.[entityId]; + if (exposedEntityConfig?.hidden) { + return undefined; + } + + return { + id: SimpleLocalMapper.slug(entityId), + uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(entityId)}`, + name: exposedEntityConfig?.name || stringValue(attributes.emulated_hue_name) || stringValue(attributes.friendly_name) || stringValue(stateArg.name) || entityId, + platform, + state: hueStateValue(domain, stateArg.state), + available: stringValue(stateArg.state) !== 'unavailable', + writable: true, + attributes: { + entityId, + hueNumber: indexArg + 1, + hueReachable: stringValue(stateArg.state) !== 'unavailable', + brightness: attributes.brightness, + colorTempKelvin: attributes.color_temp_kelvin, + hsColor: attributes.hs_color, + supportedColorModes: attributes.supported_color_modes, + supportedFeatures: attributes.supported_features, + }, + }; +}; + +const hueLightEntities = (valueArg: unknown): ISimpleLocalEntitySnapshot[] => { + if (!isRecord(valueArg)) { + return []; + } + return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record] => isRecord(entryArg[1])).map(([numberArg, lightArg]) => { + const state = recordValue(lightArg.state) || {}; + return { + id: `hue_${SimpleLocalMapper.slug(numberArg)}`, + uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(stringValue(lightArg.uniqueid) || numberArg)}`, + name: stringValue(lightArg.name) || `Hue ${numberArg}`, + platform: 'light', + state: state.on === true, + available: state.reachable !== false, + writable: true, + attributes: { + hueNumber: numberArg, + hueUniqueId: lightArg.uniqueid, + hueType: lightArg.type, + modelId: lightArg.modelid, + brightness: state.bri, + colorMode: state.colormode, + }, + }; + }); +}; + +const endpointInfo = (configArg: IEmulatedHueConfig, rawDataArg: Record, hueConfigArg: Record): { host?: string; port?: number } => { + const ipAddress = stringValue(hueConfigArg.ipaddress) || stringValue(rawDataArg.ipaddress); + const parsed = parseHostPort(ipAddress); + const host = configArg.host || configArg.hostIp || configArg.host_ip || stringValue(rawDataArg.host_ip) || stringValue(rawDataArg.hostIp) || parsed?.host; + const port = configArg.port || configArg.listenPort || configArg.listen_port || numberValue(rawDataArg.listen_port) || numberValue(rawDataArg.listenPort) || parsed?.port || (host ? emulatedHueDefaultPort : undefined); + return { host, port }; +}; + +const parseHostPort = (valueArg?: string): { host: string; port?: number } | undefined => { + if (!valueArg) { + return undefined; + } + const [host, port] = valueArg.split(':'); + return host ? { host, port: port ? Number(port) : undefined } : undefined; +}; + +const stateRecords = (valueArg: unknown): Array> => { + if (Array.isArray(valueArg)) { + return valueArg.filter(isRecord); + } + if (isRecord(valueArg)) { + return Object.values(valueArg).filter(isRecord); + } + return []; +}; + +const hueStateValue = (domainArg: string, valueArg: unknown): boolean | string => { + const value = stringValue(valueArg); + if (!value) { + return false; + } + if (domainArg === 'cover') { + return value !== 'closed'; + } + return !offStates.has(value); +}; + +const hasSimpleEntities = (valueArg: Record): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg && !('entity_id' in entityArg)); +const isSnapshotLike = (valueArg: Record): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities); +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined; +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; diff --git a/ts/integrations/emulated_hue/emulated_hue.types.ts b/ts/integrations/emulated_hue/emulated_hue.types.ts index a4581ee..2b4fde4 100644 --- a/ts/integrations/emulated_hue/emulated_hue.types.ts +++ b/ts/integrations/emulated_hue/emulated_hue.types.ts @@ -1,4 +1,148 @@ -export interface IHomeAssistantEmulatedHueConfig { - // TODO: replace with the TypeScript-native config for emulated_hue. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const emulatedHueDomain = 'emulated_hue'; +export const emulatedHueDefaultName = 'Emulated Hue'; +export const emulatedHueDefaultPort = 8300; +export const emulatedHueSerialNumber = '001788FFFE23BFC2'; +export const emulatedHueUuid = '2f402f80-da50-11e1-9b23-001788255acc'; + +export type TEmulatedHueRawData = TSimpleLocalRawData; +export interface IEmulatedHueSnapshot extends ISimpleLocalSnapshot {} +export interface IEmulatedHueConfig extends ISimpleLocalConfig { + hostIp?: string; + host_ip?: string; + listenPort?: number; + listen_port?: number; + advertiseIp?: string; + advertise_ip?: string; + advertisePort?: number; + advertise_port?: number; + exposeByDefault?: boolean; + expose_by_default?: boolean; + exposedDomains?: string[]; + exposed_domains?: string[]; + lightsAllDimmable?: boolean; + lights_all_dimmable?: boolean; + type?: 'alexa' | 'google_home' | string; + exposedEntities?: Record; } +export interface IHomeAssistantEmulatedHueConfig extends IEmulatedHueConfig {} + +const emulatedHueControlServices = [ + 'turn_on', + 'turn_off', + 'toggle', + 'set_level', + 'set_temperature', + 'open_cover', + 'close_cover', + 'set_value', + 'select_source', + 'volume_up', + 'volume_down', + 'volume_mute', + 'media_play', + 'media_pause', + 'media_stop', +]; + +export const emulatedHueProfile: ISimpleLocalIntegrationProfile = { + domain: 'emulated_hue', + displayName: 'Emulated Hue', + manufacturer: 'Home Assistant', + model: 'Emulated Hue Bridge', + defaultName: emulatedHueDefaultName, + defaultPort: emulatedHueDefaultPort, + defaultProtocol: 'upnp', + status: 'control-runtime', + platforms: [ + 'light', + 'switch', + 'media_player', + 'fan', + 'cover', + 'climate', + 'button', + ], + serviceDomains: [ + 'light', + 'switch', + 'media_player', + 'fan', + 'cover', + 'climate', + 'button', + ], + controlServices: emulatedHueControlServices, + discoverySources: [ + 'manual', + 'ssdp', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'emulated hue', + 'hass bridge', + 'philips hue', + 'hue bridge', + 'upnp', + 'alexa', + 'google_home', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/emulated_hue', + upstreamDomain: 'emulated_hue', + iotClass: 'local_push', + qualityScale: 'internal', + requirements: [], + dependencies: [ + 'network', + ], + afterDependencies: [ + 'http', + ], + codeowners: [ + '@bdraco', + '@Tho85', + ], + configFlow: false, + hue: { + serialNumber: emulatedHueSerialNumber, + uuid: emulatedHueUuid, + defaultListenPort: emulatedHueDefaultPort, + defaultType: 'google_home', + }, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...emulatedHueControlServices, + ], + platforms: [ + 'light', + 'switch', + 'media_player', + 'fan', + 'cover', + 'climate', + 'button', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual bridge setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'Hue API-compatible state mapping for exposed local Home Assistant entity snapshots', + 'executor-gated local control dispatch through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'starting the HTTP and UPNP responder without a host application', + 'claiming Hue API command success without injected client.execute or commandExecutor', + 'accepting remote non-local Hue API callers', + ], + }, + }, +}; diff --git a/ts/integrations/emulated_hue/index.ts b/ts/integrations/emulated_hue/index.ts index 8e9d415..d2af6da 100644 --- a/ts/integrations/emulated_hue/index.ts +++ b/ts/integrations/emulated_hue/index.ts @@ -1,2 +1,6 @@ +export * from './emulated_hue.classes.client.js'; +export * from './emulated_hue.classes.configflow.js'; export * from './emulated_hue.classes.integration.js'; +export * from './emulated_hue.discovery.js'; +export * from './emulated_hue.mapper.js'; export * from './emulated_hue.types.js'; diff --git a/ts/integrations/emulated_kasa/.generated-by-smarthome-exchange b/ts/integrations/emulated_kasa/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/emulated_kasa/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/emulated_kasa/emulated_kasa.classes.client.ts b/ts/integrations/emulated_kasa/emulated_kasa.classes.client.ts new file mode 100644 index 0000000..3856e07 --- /dev/null +++ b/ts/integrations/emulated_kasa/emulated_kasa.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EmulatedKasaMapper } from './emulated_kasa.mapper.js'; +import type { IEmulatedKasaConfig, IEmulatedKasaSnapshot } from './emulated_kasa.types.js'; +import { emulatedKasaProfile } from './emulated_kasa.types.js'; + +export class EmulatedKasaClient extends SimpleLocalClient { + constructor(private readonly configArg: IEmulatedKasaConfig) { + super(emulatedKasaProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EmulatedKasaMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EmulatedKasaMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/emulated_kasa/emulated_kasa.classes.configflow.ts b/ts/integrations/emulated_kasa/emulated_kasa.classes.configflow.ts new file mode 100644 index 0000000..450acb1 --- /dev/null +++ b/ts/integrations/emulated_kasa/emulated_kasa.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEmulatedKasaConfig } from './emulated_kasa.types.js'; +import { emulatedKasaProfile } from './emulated_kasa.types.js'; + +export class EmulatedKasaConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(emulatedKasaProfile); + } +} diff --git a/ts/integrations/emulated_kasa/emulated_kasa.classes.integration.ts b/ts/integrations/emulated_kasa/emulated_kasa.classes.integration.ts index 9af6acc..3d86d3c 100644 --- a/ts/integrations/emulated_kasa/emulated_kasa.classes.integration.ts +++ b/ts/integrations/emulated_kasa/emulated_kasa.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EmulatedKasaClient } from './emulated_kasa.classes.client.js'; +import { EmulatedKasaConfigFlow } from './emulated_kasa.classes.configflow.js'; +import { createEmulatedKasaDiscoveryDescriptor } from './emulated_kasa.discovery.js'; +import type { IEmulatedKasaConfig } from './emulated_kasa.types.js'; +import { emulatedKasaDomain, emulatedKasaProfile } from './emulated_kasa.types.js'; + +export class EmulatedKasaIntegration extends SimpleLocalIntegration { + public readonly domain = emulatedKasaDomain; + public readonly discoveryDescriptor = createEmulatedKasaDiscoveryDescriptor(); + public readonly configFlow = new EmulatedKasaConfigFlow(); -export class HomeAssistantEmulatedKasaIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "emulated_kasa", - displayName: "Emulated Kasa", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/emulated_kasa", - "upstreamDomain": "emulated_kasa", - "iotClass": "local_push", - "qualityScale": "internal", - "requirements": [ - "sense-energy==0.14.1" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@kbickar" - ] -}, - }); + super(emulatedKasaProfile); + } + + public async setup(configArg: IEmulatedKasaConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(emulatedKasaProfile, new EmulatedKasaClient(configArg)); } } + +export class HomeAssistantEmulatedKasaIntegration extends EmulatedKasaIntegration {} diff --git a/ts/integrations/emulated_kasa/emulated_kasa.discovery.ts b/ts/integrations/emulated_kasa/emulated_kasa.discovery.ts new file mode 100644 index 0000000..ce7babb --- /dev/null +++ b/ts/integrations/emulated_kasa/emulated_kasa.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { emulatedKasaProfile } from './emulated_kasa.types.js'; + +export const createEmulatedKasaDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedKasaProfile); diff --git a/ts/integrations/emulated_kasa/emulated_kasa.mapper.ts b/ts/integrations/emulated_kasa/emulated_kasa.mapper.ts new file mode 100644 index 0000000..4385129 --- /dev/null +++ b/ts/integrations/emulated_kasa/emulated_kasa.mapper.ts @@ -0,0 +1,185 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEmulatedKasaConfig } from './emulated_kasa.types.js'; +import { emulatedKasaDefaultName, emulatedKasaProfile } from './emulated_kasa.types.js'; + +export class EmulatedKasaMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: emulatedKasaProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEmulatedKasaConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(emulatedKasaProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(emulatedKasaProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEmulatedKasaConfig, rawDataArg: unknown): unknown { + if (isSimpleSnapshot(rawDataArg)) { + return rawDataArg; + } + + const plugRecords = plugEntries(rawDataArg); + if (!plugRecords.length) { + return rawDataArg; + } + + const rawRecord = recordValue(rawDataArg); + const name = configArg.name || stringValue(rawRecord?.name) || emulatedKasaDefaultName; + const host = configArg.host || stringValue(rawRecord?.host); + const entities = plugRecords.flatMap(({ id, record }) => kasaEntities(id, record)); + if (!entities.length) { + return rawDataArg; + } + + return { + device: { + id: configArg.uniqueId || stringValue(rawRecord?.id) || host || emulatedKasaProfile.domain, + name, + manufacturer: emulatedKasaProfile.manufacturer, + model: emulatedKasaProfile.model, + host, + port: configArg.port || numberValue(rawRecord?.port), + protocol: emulatedKasaProfile.defaultProtocol, + attributes: { + emulatedPlugCount: plugRecords.length, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const kasaEntities = (fallbackIdArg: string, recordArg: Record): ISimpleLocalEntitySnapshot[] => { + const entityId = stringValue(recordArg.entity_id) || stringValue(recordArg.entityId) || fallbackIdArg; + const alias = stringValue(recordArg.name) || stringValue(recordArg.alias) || titleFromEntityId(entityId); + const slug = SimpleLocalMapper.slug(entityId || alias); + const domain = stringValue(recordArg.domain) || entityId.split('.')[0]; + const rawState = recordArg.state ?? recordArg.is_on ?? recordArg.isOn; + const onState = switchState(rawState); + const powerEntity = stringValue(recordArg.power_entity) || stringValue(recordArg.powerEntity); + let power = numberValue(recordArg.power ?? recordArg.power_w ?? recordArg.powerW ?? recordArg.watts ?? recordArg.value); + + if (power === undefined && (domain === 'sensor' || entityId.startsWith('sensor.'))) { + power = numberValue(rawState); + } + if (power === undefined && onState === false) { + power = 0; + } + + const entities: ISimpleLocalEntitySnapshot[] = []; + if (power !== undefined) { + entities.push({ + id: `${slug}_power`, + uniqueId: `${emulatedKasaProfile.domain}_${slug}_power`, + name: `${alias} Power`, + platform: 'sensor', + state: power, + available: true, + writable: false, + unit: 'W', + deviceClass: 'power', + attributes: { + entityId, + powerEntity, + }, + }); + } + if (onState !== undefined && domain !== 'sensor') { + entities.push({ + id: `${slug}_state`, + uniqueId: `${emulatedKasaProfile.domain}_${slug}_state`, + name: `${alias} State`, + platform: 'binary_sensor', + state: onState, + available: true, + writable: false, + attributes: { + entityId, + }, + }); + } + return entities; +}; + +const plugEntries = (valueArg: unknown): Array<{ id: string; record: Record }> => { + if (Array.isArray(valueArg)) { + return valueArg.map((entryArg, indexArg) => ({ id: `plug_${indexArg + 1}`, record: recordValue(entryArg) })).filter((entryArg): entryArg is { id: string; record: Record } => Boolean(entryArg.record)); + } + + const rawRecord = recordValue(valueArg); + if (!rawRecord) { + return []; + } + + const nested = rawRecord.entities ?? rawRecord.plugs ?? rawRecord.devices; + if (Array.isArray(nested)) { + return plugEntries(nested); + } + const nestedRecord = recordValue(nested); + if (nestedRecord) { + return Object.entries(nestedRecord) + .map(([idArg, entryArg]) => ({ id: idArg, record: recordValue(entryArg) })) + .filter((entryArg): entryArg is { id: string; record: Record } => Boolean(entryArg.record)); + } + if ('power' in rawRecord || 'power_w' in rawRecord || 'powerW' in rawRecord || 'state' in rawRecord) { + return [{ id: stringValue(rawRecord.entity_id) || stringValue(rawRecord.entityId) || stringValue(rawRecord.id) || 'plug', record: rawRecord }]; + } + return []; +}; + +const switchState = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['on', 'true', '1', 'open', 'home'].includes(value)) { + return true; + } + if (['off', 'false', '0', 'closed', 'not_home', 'unavailable', 'unknown'].includes(value)) { + return false; + } + return undefined; +}; + +const titleFromEntityId = (valueArg: string): string => { + const [, objectId = valueArg] = valueArg.split('.'); + return objectId.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase()); +}; + +const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities)); +const recordValue = (valueArg: unknown): Record | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; +}; diff --git a/ts/integrations/emulated_kasa/emulated_kasa.types.ts b/ts/integrations/emulated_kasa/emulated_kasa.types.ts index d758412..e530272 100644 --- a/ts/integrations/emulated_kasa/emulated_kasa.types.ts +++ b/ts/integrations/emulated_kasa/emulated_kasa.types.ts @@ -1,4 +1,78 @@ -export interface IHomeAssistantEmulatedKasaConfig { - // TODO: replace with the TypeScript-native config for emulated_kasa. - [key: string]: unknown; -} +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const emulatedKasaDomain = 'emulated_kasa'; +export const emulatedKasaDefaultName = 'Emulated Kasa'; + +export type TEmulatedKasaRawData = TSimpleLocalRawData; +export interface IEmulatedKasaSnapshot extends ISimpleLocalSnapshot {} +export interface IEmulatedKasaConfig extends ISimpleLocalConfig {} +export interface IHomeAssistantEmulatedKasaConfig extends IEmulatedKasaConfig {} + +export const emulatedKasaProfile: ISimpleLocalIntegrationProfile = { + domain: 'emulated_kasa', + displayName: 'Emulated Kasa', + manufacturer: 'Home Assistant', + model: 'TP-Link Kasa Emulator', + defaultName: emulatedKasaDefaultName, + defaultPort: 9999, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'sensor', + 'binary_sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'emulated kasa', + 'kasa', + 'tp-link', + 'tplink', + 'sense energy', + 'power', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/emulated_kasa', + upstreamDomain: 'emulated_kasa', + iotClass: 'local_push', + qualityScale: 'internal', + requirements: [ + 'sense-energy==0.14.1', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@kbickar', + ], + configFlow: false, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + 'binary_sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local setup for configured HA entities exposed as emulated Kasa plug power snapshots', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + ], + explicitUnsupported: [ + 'claiming live Kasa emulation server startup or UDP discovery without an injected native client', + 'claiming live command success without injected client.execute or commandExecutor', + 'Sense Energy cloud account operations', + ], + }, + }, +}; diff --git a/ts/integrations/emulated_kasa/index.ts b/ts/integrations/emulated_kasa/index.ts index 180da78..c07be28 100644 --- a/ts/integrations/emulated_kasa/index.ts +++ b/ts/integrations/emulated_kasa/index.ts @@ -1,2 +1,6 @@ +export * from './emulated_kasa.classes.client.js'; +export * from './emulated_kasa.classes.configflow.js'; export * from './emulated_kasa.classes.integration.js'; +export * from './emulated_kasa.discovery.js'; +export * from './emulated_kasa.mapper.js'; export * from './emulated_kasa.types.js'; diff --git a/ts/integrations/emulated_roku/.generated-by-smarthome-exchange b/ts/integrations/emulated_roku/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/emulated_roku/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/emulated_roku/emulated_roku.classes.client.ts b/ts/integrations/emulated_roku/emulated_roku.classes.client.ts new file mode 100644 index 0000000..75dbaba --- /dev/null +++ b/ts/integrations/emulated_roku/emulated_roku.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EmulatedRokuMapper } from './emulated_roku.mapper.js'; +import type { IEmulatedRokuConfig, IEmulatedRokuSnapshot } from './emulated_roku.types.js'; +import { emulatedRokuProfile } from './emulated_roku.types.js'; + +export class EmulatedRokuClient extends SimpleLocalClient { + constructor(private readonly configArg: IEmulatedRokuConfig) { + super(emulatedRokuProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EmulatedRokuMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EmulatedRokuMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/emulated_roku/emulated_roku.classes.configflow.ts b/ts/integrations/emulated_roku/emulated_roku.classes.configflow.ts new file mode 100644 index 0000000..d63aafc --- /dev/null +++ b/ts/integrations/emulated_roku/emulated_roku.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEmulatedRokuConfig } from './emulated_roku.types.js'; +import { emulatedRokuProfile } from './emulated_roku.types.js'; + +export class EmulatedRokuConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(emulatedRokuProfile); + } +} diff --git a/ts/integrations/emulated_roku/emulated_roku.classes.integration.ts b/ts/integrations/emulated_roku/emulated_roku.classes.integration.ts index 6d2e7fa..cba08bb 100644 --- a/ts/integrations/emulated_roku/emulated_roku.classes.integration.ts +++ b/ts/integrations/emulated_roku/emulated_roku.classes.integration.ts @@ -1,25 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EmulatedRokuClient } from './emulated_roku.classes.client.js'; +import { EmulatedRokuConfigFlow } from './emulated_roku.classes.configflow.js'; +import { createEmulatedRokuDiscoveryDescriptor } from './emulated_roku.discovery.js'; +import type { IEmulatedRokuConfig } from './emulated_roku.types.js'; +import { emulatedRokuDomain, emulatedRokuProfile } from './emulated_roku.types.js'; + +export class EmulatedRokuIntegration extends SimpleLocalIntegration { + public readonly domain = emulatedRokuDomain; + public readonly discoveryDescriptor = createEmulatedRokuDiscoveryDescriptor(); + public readonly configFlow = new EmulatedRokuConfigFlow(); -export class HomeAssistantEmulatedRokuIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "emulated_roku", - displayName: "Emulated Roku", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/emulated_roku", - "upstreamDomain": "emulated_roku", - "iotClass": "local_push", - "requirements": [ - "emulated-roku==0.3.0" - ], - "dependencies": [ - "network" - ], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(emulatedRokuProfile); + } + + public async setup(configArg: IEmulatedRokuConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(emulatedRokuProfile, new EmulatedRokuClient(configArg)); } } + +export class HomeAssistantEmulatedRokuIntegration extends EmulatedRokuIntegration {} diff --git a/ts/integrations/emulated_roku/emulated_roku.discovery.ts b/ts/integrations/emulated_roku/emulated_roku.discovery.ts new file mode 100644 index 0000000..97f85a2 --- /dev/null +++ b/ts/integrations/emulated_roku/emulated_roku.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { emulatedRokuProfile } from './emulated_roku.types.js'; + +export const createEmulatedRokuDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedRokuProfile); diff --git a/ts/integrations/emulated_roku/emulated_roku.mapper.ts b/ts/integrations/emulated_roku/emulated_roku.mapper.ts new file mode 100644 index 0000000..5c38ff2 --- /dev/null +++ b/ts/integrations/emulated_roku/emulated_roku.mapper.ts @@ -0,0 +1,158 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEmulatedRokuConfig } from './emulated_roku.types.js'; +import { emulatedRokuDefaultName, emulatedRokuProfile } from './emulated_roku.types.js'; + +export class EmulatedRokuMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: emulatedRokuProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEmulatedRokuConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(emulatedRokuProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(emulatedRokuProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEmulatedRokuConfig, rawDataArg: unknown): unknown { + if (isSimpleSnapshot(rawDataArg)) { + return rawDataArg; + } + + const servers = serverEntries(configArg, rawDataArg); + if (!servers.length) { + return rawDataArg; + } + + const rawRecord = recordValue(rawDataArg); + const firstServer = servers[0]; + const host = configArg.host || stringValue(rawRecord?.host_ip) || stringValue(rawRecord?.hostIp) || stringValue(firstServer.hostIp); + const listenPort = configArg.listenPort || configArg.port || firstServer.listenPort || emulatedRokuProfile.defaultPort; + const name = configArg.name || stringValue(rawRecord?.name) || firstServer.name || emulatedRokuDefaultName; + const entities = servers.map((serverArg) => serverEntity(serverArg)); + + return { + device: { + id: configArg.uniqueId || host || name, + name, + manufacturer: emulatedRokuProfile.manufacturer, + model: emulatedRokuProfile.model, + host, + port: listenPort, + protocol: emulatedRokuProfile.defaultProtocol, + attributes: { + serverCount: servers.length, + eventType: 'roku_command', + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +interface IServerRecord { + id: string; + name: string; + hostIp?: string; + listenPort?: number; + advertiseIp?: string; + advertisePort?: number; + upnpBindMulticast?: boolean; + state: string; +} + +const serverEntity = (serverArg: IServerRecord): ISimpleLocalEntitySnapshot => { + const slug = SimpleLocalMapper.slug(serverArg.id || serverArg.name); + return { + id: `${slug}_server`, + uniqueId: `${emulatedRokuProfile.domain}_${slug}_server`, + name: `${serverArg.name} Server`, + platform: 'sensor', + state: serverArg.state, + available: true, + writable: false, + attributes: { + hostIp: serverArg.hostIp, + listenPort: serverArg.listenPort, + advertiseIp: serverArg.advertiseIp, + advertisePort: serverArg.advertisePort, + upnpBindMulticast: serverArg.upnpBindMulticast, + eventType: 'roku_command', + }, + }; +}; + +const serverEntries = (configArg: IEmulatedRokuConfig, valueArg: unknown): IServerRecord[] => { + const rawRecord = recordValue(valueArg); + const nested = rawRecord?.servers ?? rawRecord?.server; + const records = Array.isArray(nested) ? nested : nested ? [nested] : rawRecord ? [rawRecord] : []; + const servers = records.map((entryArg, indexArg) => toServerRecord(configArg, entryArg, indexArg)).filter((entryArg): entryArg is IServerRecord => Boolean(entryArg)); + if (servers.length) { + return servers; + } + if (configArg.name || configArg.host || configArg.listenPort || configArg.port) { + return [{ + id: configArg.uniqueId || configArg.name || configArg.host || emulatedRokuDefaultName, + name: configArg.name || emulatedRokuDefaultName, + hostIp: configArg.host, + listenPort: configArg.listenPort || configArg.port || emulatedRokuProfile.defaultPort, + advertiseIp: configArg.advertiseIp, + advertisePort: configArg.advertisePort, + upnpBindMulticast: configArg.upnpBindMulticast, + state: configArg.online === false ? 'stopped' : 'configured', + }]; + } + return []; +}; + +const toServerRecord = (configArg: IEmulatedRokuConfig, valueArg: unknown, indexArg: number): IServerRecord | undefined => { + const record = recordValue(valueArg); + if (!record) { + return undefined; + } + const name = stringValue(record.name) || (indexArg === 0 ? configArg.name : undefined) || `${emulatedRokuDefaultName} ${indexArg + 1}`; + const listenPort = numberValue(record.listen_port) || numberValue(record.listenPort) || numberValue(record.port) || (indexArg === 0 ? configArg.listenPort || configArg.port : undefined) || emulatedRokuProfile.defaultPort; + const hostIp = stringValue(record.host_ip) || stringValue(record.hostIp) || stringValue(record.host) || (indexArg === 0 ? configArg.host : undefined); + return { + id: stringValue(record.id) || `${name}:${listenPort}`, + name, + hostIp, + listenPort, + advertiseIp: stringValue(record.advertise_ip) || stringValue(record.advertiseIp) || (indexArg === 0 ? configArg.advertiseIp : undefined), + advertisePort: numberValue(record.advertise_port) || numberValue(record.advertisePort) || (indexArg === 0 ? configArg.advertisePort : undefined), + upnpBindMulticast: booleanValue(record.upnp_bind_multicast) ?? booleanValue(record.upnpBindMulticast) ?? (indexArg === 0 ? configArg.upnpBindMulticast : undefined), + state: stringValue(record.state) || 'configured', + }; +}; + +const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities)); +const recordValue = (valueArg: unknown): Record | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return Math.round(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Math.round(Number(valueArg)); + } + return undefined; +}; +const booleanValue = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined; diff --git a/ts/integrations/emulated_roku/emulated_roku.types.ts b/ts/integrations/emulated_roku/emulated_roku.types.ts index 3036742..1545136 100644 --- a/ts/integrations/emulated_roku/emulated_roku.types.ts +++ b/ts/integrations/emulated_roku/emulated_roku.types.ts @@ -1,4 +1,80 @@ -export interface IHomeAssistantEmulatedRokuConfig { - // TODO: replace with the TypeScript-native config for emulated_roku. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const emulatedRokuDomain = 'emulated_roku'; +export const emulatedRokuDefaultName = 'Home Assistant'; + +export type TEmulatedRokuRawData = TSimpleLocalRawData; +export interface IEmulatedRokuSnapshot extends ISimpleLocalSnapshot {} +export interface IEmulatedRokuConfig extends ISimpleLocalConfig { + listenPort?: number; + advertiseIp?: string; + advertisePort?: number; + upnpBindMulticast?: boolean; } +export interface IHomeAssistantEmulatedRokuConfig extends IEmulatedRokuConfig {} + +export const emulatedRokuProfile: ISimpleLocalIntegrationProfile = { + domain: 'emulated_roku', + displayName: 'Emulated Roku', + manufacturer: 'Home Assistant', + model: 'Roku API Emulator', + defaultName: emulatedRokuDefaultName, + defaultPort: 8060, + defaultProtocol: 'upnp', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'ssdp', + 'custom', + ], + discoveryKeywords: [ + 'emulated roku', + 'roku', + 'upnp', + 'remote', + 'roku_command', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/emulated_roku', + upstreamDomain: 'emulated_roku', + iotClass: 'local_push', + qualityScale: undefined, + requirements: [ + 'emulated-roku==0.3.0', + ], + dependencies: [ + 'network', + ], + afterDependencies: [], + codeowners: [], + configFlow: true, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local setup for configured Emulated Roku server snapshots', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + ], + explicitUnsupported: [ + 'claiming live Emulated Roku server startup, SSDP advertisement, or HTTP endpoint binding without an injected native client', + 'claiming live command success without injected client.execute or commandExecutor', + ], + }, + }, +}; diff --git a/ts/integrations/emulated_roku/index.ts b/ts/integrations/emulated_roku/index.ts index fa81504..492fc49 100644 --- a/ts/integrations/emulated_roku/index.ts +++ b/ts/integrations/emulated_roku/index.ts @@ -1,2 +1,6 @@ +export * from './emulated_roku.classes.client.js'; +export * from './emulated_roku.classes.configflow.js'; export * from './emulated_roku.classes.integration.js'; +export * from './emulated_roku.discovery.js'; +export * from './emulated_roku.mapper.js'; export * from './emulated_roku.types.js'; diff --git a/ts/integrations/energenie_power_sockets/.generated-by-smarthome-exchange b/ts/integrations/energenie_power_sockets/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/energenie_power_sockets/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.client.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.client.ts new file mode 100644 index 0000000..e1a83e5 --- /dev/null +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EnergeniePowerSocketsMapper } from './energenie_power_sockets.mapper.js'; +import type { IEnergeniePowerSocketsConfig, IEnergeniePowerSocketsSnapshot } from './energenie_power_sockets.types.js'; +import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js'; + +export class EnergeniePowerSocketsClient extends SimpleLocalClient { + constructor(private readonly configArg: IEnergeniePowerSocketsConfig) { + super(energeniePowerSocketsProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EnergeniePowerSocketsMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EnergeniePowerSocketsMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.configflow.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.configflow.ts new file mode 100644 index 0000000..9f5aded --- /dev/null +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js'; +import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js'; + +export class EnergeniePowerSocketsConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(energeniePowerSocketsProfile); + } +} diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.integration.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.integration.ts index 209c31b..dbd297e 100644 --- a/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.integration.ts +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EnergeniePowerSocketsClient } from './energenie_power_sockets.classes.client.js'; +import { EnergeniePowerSocketsConfigFlow } from './energenie_power_sockets.classes.configflow.js'; +import { createEnergeniePowerSocketsDiscoveryDescriptor } from './energenie_power_sockets.discovery.js'; +import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js'; +import { energeniePowerSocketsDomain, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js'; + +export class EnergeniePowerSocketsIntegration extends SimpleLocalIntegration { + public readonly domain = energeniePowerSocketsDomain; + public readonly discoveryDescriptor = createEnergeniePowerSocketsDiscoveryDescriptor(); + public readonly configFlow = new EnergeniePowerSocketsConfigFlow(); -export class HomeAssistantEnergeniePowerSocketsIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "energenie_power_sockets", - displayName: "Energenie Power Sockets", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/energenie_power_sockets", - "upstreamDomain": "energenie_power_sockets", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "pyegps==0.2.5" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@gnumpi" - ] -}, - }); + super(energeniePowerSocketsProfile); + } + + public async setup(configArg: IEnergeniePowerSocketsConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(energeniePowerSocketsProfile, new EnergeniePowerSocketsClient(configArg)); } } + +export class HomeAssistantEnergeniePowerSocketsIntegration extends EnergeniePowerSocketsIntegration {} diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.discovery.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.discovery.ts new file mode 100644 index 0000000..daae3f8 --- /dev/null +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js'; + +export const createEnergeniePowerSocketsDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(energeniePowerSocketsProfile); diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.mapper.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.mapper.ts new file mode 100644 index 0000000..1b0a387 --- /dev/null +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.mapper.ts @@ -0,0 +1,162 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js'; +import { energeniePowerSocketsDefaultName, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js'; + +export class EnergeniePowerSocketsMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: energeniePowerSocketsProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEnergeniePowerSocketsConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(energeniePowerSocketsProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(energeniePowerSocketsProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEnergeniePowerSocketsConfig, rawDataArg: unknown): unknown { + if (isSimpleSnapshot(rawDataArg)) { + return rawDataArg; + } + + const rawRecord = recordValue(rawDataArg); + if (!rawRecord) { + return rawDataArg; + } + + const deviceApiId = configArg.deviceApiId || stringValue(configArg['api-device-id']) || stringValue(rawRecord.device_id) || stringValue(rawRecord.deviceId) || stringValue(rawRecord.id); + const numberOfSockets = configArg.numberOfSockets || numberValue(rawRecord.numberOfSockets) || numberValue(rawRecord.number_of_sockets) || socketCount(rawRecord); + const sockets = socketEntries(rawRecord, numberOfSockets); + if (!sockets.length) { + return rawDataArg; + } + + const name = configArg.name || stringValue(rawRecord.name) || energeniePowerSocketsDefaultName; + const manufacturer = stringValue(rawRecord.manufacturer) || energeniePowerSocketsProfile.manufacturer; + const entities = sockets.map((socketArg) => socketEntity(deviceApiId || name, socketArg)); + + return { + device: { + id: configArg.uniqueId || deviceApiId || name, + name, + manufacturer, + model: stringValue(rawRecord.model) || stringValue(rawRecord.name) || energeniePowerSocketsProfile.model, + serialNumber: deviceApiId, + protocol: energeniePowerSocketsProfile.defaultProtocol, + attributes: { + deviceApiId, + numberOfSockets: sockets.length, + swVersion: stringValue(rawRecord.sw_version) || stringValue(rawRecord.swVersion), + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +interface ISocketRecord { + socketId: number; + state: boolean | null; + available: boolean; +} + +const socketEntity = (deviceIdArg: string, socketArg: ISocketRecord): ISimpleLocalEntitySnapshot => { + const base = `${SimpleLocalMapper.slug(deviceIdArg)}_${socketArg.socketId}`; + return { + id: `socket_${socketArg.socketId}`, + uniqueId: `${energeniePowerSocketsProfile.domain}_${base}`, + name: `Socket ${socketArg.socketId}`, + platform: 'switch', + state: socketArg.state, + available: socketArg.available, + writable: true, + attributes: { + socketId: socketArg.socketId, + }, + }; +}; + +const socketEntries = (rawRecordArg: Record, numberOfSocketsArg: number | undefined): ISocketRecord[] => { + const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states; + if (Array.isArray(sockets)) { + return sockets.map((valueArg, indexArg) => toSocketRecord(indexArg, valueArg)); + } + const socketRecord = recordValue(sockets); + if (socketRecord) { + return Object.entries(socketRecord).map(([socketIdArg, valueArg]) => toSocketRecord(numberValue(socketIdArg) ?? 0, valueArg)); + } + if (numberOfSocketsArg && numberOfSocketsArg > 0) { + return Array.from({ length: numberOfSocketsArg }, (_valueArg, indexArg) => ({ socketId: indexArg, state: null, available: false })); + } + return []; +}; + +const toSocketRecord = (socketIdArg: number, valueArg: unknown): ISocketRecord => { + const record = recordValue(valueArg); + const state = switchState(record ? record.state ?? record.is_on ?? record.isOn ?? record.on ?? record.status : valueArg); + return { + socketId: socketIdArg, + state: state ?? null, + available: state !== undefined, + }; +}; + +const socketCount = (rawRecordArg: Record): number | undefined => { + const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states; + if (Array.isArray(sockets)) { + return sockets.length; + } + const socketRecord = recordValue(sockets); + return socketRecord ? Object.keys(socketRecord).length : undefined; +}; + +const switchState = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['on', 'true', '1'].includes(value)) { + return true; + } + if (['off', 'false', '0'].includes(value)) { + return false; + } + return undefined; +}; + +const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities)); +const recordValue = (valueArg: unknown): Record | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return Math.round(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Math.round(Number(valueArg)); + } + return undefined; +}; diff --git a/ts/integrations/energenie_power_sockets/energenie_power_sockets.types.ts b/ts/integrations/energenie_power_sockets/energenie_power_sockets.types.ts index 52b3c79..1d64520 100644 --- a/ts/integrations/energenie_power_sockets/energenie_power_sockets.types.ts +++ b/ts/integrations/energenie_power_sockets/energenie_power_sockets.types.ts @@ -1,4 +1,88 @@ -export interface IHomeAssistantEnergeniePowerSocketsConfig { - // TODO: replace with the TypeScript-native config for energenie_power_sockets. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const energeniePowerSocketsDomain = 'energenie_power_sockets'; +export const energeniePowerSocketsDefaultName = 'Energenie Power Sockets'; + +export type TEnergeniePowerSocketsRawData = TSimpleLocalRawData; +export interface IEnergeniePowerSocketsSnapshot extends ISimpleLocalSnapshot {} +export interface IEnergeniePowerSocketsConfig extends ISimpleLocalConfig { + deviceApiId?: string; + numberOfSockets?: number; } +export interface IHomeAssistantEnergeniePowerSocketsConfig extends IEnergeniePowerSocketsConfig {} + +export const energeniePowerSocketsProfile: ISimpleLocalIntegrationProfile = { + domain: 'energenie_power_sockets', + displayName: 'Energenie Power Sockets', + manufacturer: 'Energenie', + model: 'PowerStrip USB', + defaultName: energeniePowerSocketsDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'switch', + ], + serviceDomains: [ + 'switch', + ], + controlServices: [ + 'turn_on', + 'turn_off', + 'toggle', + ], + discoverySources: [ + 'manual', + 'usb', + 'custom', + ], + discoveryKeywords: [ + 'energenie', + 'power sockets', + 'powerstrip', + 'pyegps', + 'usb', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/energenie_power_sockets', + upstreamDomain: 'energenie_power_sockets', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'pyegps==0.2.5', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@gnumpi', + ], + configFlow: true, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'turn_on', + 'turn_off', + 'toggle', + ], + platforms: [ + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local USB power-strip setup from raw pyegps-style snapshots', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + 'executor-routed socket switch control when client.execute or commandExecutor is supplied', + ], + explicitUnsupported: [ + 'claiming live socket switch success without injected client.execute or commandExecutor', + 'direct pyegps USB access without an injected native client', + ], + }, + }, +}; diff --git a/ts/integrations/energenie_power_sockets/index.ts b/ts/integrations/energenie_power_sockets/index.ts index ada2de8..db24954 100644 --- a/ts/integrations/energenie_power_sockets/index.ts +++ b/ts/integrations/energenie_power_sockets/index.ts @@ -1,2 +1,6 @@ +export * from './energenie_power_sockets.classes.client.js'; +export * from './energenie_power_sockets.classes.configflow.js'; export * from './energenie_power_sockets.classes.integration.js'; +export * from './energenie_power_sockets.discovery.js'; +export * from './energenie_power_sockets.mapper.js'; export * from './energenie_power_sockets.types.js'; diff --git a/ts/integrations/enigma2/.generated-by-smarthome-exchange b/ts/integrations/enigma2/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/enigma2/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/enigma2/enigma2.classes.client.ts b/ts/integrations/enigma2/enigma2.classes.client.ts new file mode 100644 index 0000000..173c4c6 --- /dev/null +++ b/ts/integrations/enigma2/enigma2.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { Enigma2Mapper } from './enigma2.mapper.js'; +import type { IEnigma2Config, IEnigma2Snapshot } from './enigma2.types.js'; +import { enigma2Profile } from './enigma2.types.js'; + +export class Enigma2Client extends SimpleLocalClient { + constructor(private readonly configArg: IEnigma2Config) { + super(enigma2Profile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return Enigma2Mapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return Enigma2Mapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/enigma2/enigma2.classes.configflow.ts b/ts/integrations/enigma2/enigma2.classes.configflow.ts new file mode 100644 index 0000000..36b1802 --- /dev/null +++ b/ts/integrations/enigma2/enigma2.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEnigma2Config } from './enigma2.types.js'; +import { enigma2Profile } from './enigma2.types.js'; + +export class Enigma2ConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(enigma2Profile); + } +} diff --git a/ts/integrations/enigma2/enigma2.classes.integration.ts b/ts/integrations/enigma2/enigma2.classes.integration.ts index 5d45b4d..cb19a1c 100644 --- a/ts/integrations/enigma2/enigma2.classes.integration.ts +++ b/ts/integrations/enigma2/enigma2.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { Enigma2Client } from './enigma2.classes.client.js'; +import { Enigma2ConfigFlow } from './enigma2.classes.configflow.js'; +import { createEnigma2DiscoveryDescriptor } from './enigma2.discovery.js'; +import type { IEnigma2Config } from './enigma2.types.js'; +import { enigma2Domain, enigma2Profile } from './enigma2.types.js'; + +export class Enigma2Integration extends SimpleLocalIntegration { + public readonly domain = enigma2Domain; + public readonly discoveryDescriptor = createEnigma2DiscoveryDescriptor(); + public readonly configFlow = new Enigma2ConfigFlow(); -export class HomeAssistantEnigma2Integration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "enigma2", - displayName: "Enigma2 (OpenWebif)", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/enigma2", - "upstreamDomain": "enigma2", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "openwebifpy==4.3.1" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@autinerd" - ] -}, - }); + super(enigma2Profile); + } + + public async setup(configArg: IEnigma2Config, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(enigma2Profile, new Enigma2Client(configArg)); } } + +export class HomeAssistantEnigma2Integration extends Enigma2Integration {} diff --git a/ts/integrations/enigma2/enigma2.discovery.ts b/ts/integrations/enigma2/enigma2.discovery.ts new file mode 100644 index 0000000..05816eb --- /dev/null +++ b/ts/integrations/enigma2/enigma2.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { enigma2Profile } from './enigma2.types.js'; + +export const createEnigma2DiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enigma2Profile); diff --git a/ts/integrations/enigma2/enigma2.mapper.ts b/ts/integrations/enigma2/enigma2.mapper.ts new file mode 100644 index 0000000..745ba3d --- /dev/null +++ b/ts/integrations/enigma2/enigma2.mapper.ts @@ -0,0 +1,138 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEnigma2Config } from './enigma2.types.js'; +import { enigma2DefaultName, enigma2DefaultPort, enigma2Profile } from './enigma2.types.js'; + +export class Enigma2Mapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: enigma2Profile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEnigma2Config, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(enigma2Profile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(enigma2Profile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEnigma2Config, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) { + return rawDataArg; + } + + const status = recordValue(rawDataArg.status) || recordValue(rawDataArg.data) || rawDataArg; + const currentService = recordValue(status.currservice) || recordValue(status.currentService) || recordValue(status.current_service) || {}; + const hasStatus = 'in_standby' in status || 'inStandby' in status || 'volume' in status || 'muted' in status || 'state' in status || Object.keys(currentService).length > 0; + if (!hasStatus) { + return rawDataArg; + } + + const about = recordValue(rawDataArg.about) || rawDataArg; + const info = recordValue(about.info) || recordValue(rawDataArg.info) || about; + const iface = firstRecord(info.ifaces) || firstRecord(info.interfaces); + const host = configArg.host || stringValue(rawDataArg.host) || stringValue(info.host); + const port = configArg.port || numberValue(rawDataArg.port) || (host ? enigma2DefaultPort : undefined); + const mac = configArg.macAddress || configArg.mac_address || stringValue(rawDataArg.mac) || stringValue(rawDataArg.macAddress) || stringValue(iface?.mac); + const name = configArg.name || stringValue(rawDataArg.name) || stringValue(status.name) || enigma2DefaultName; + const inStandby = booleanValue(status.in_standby ?? status.inStandby ?? status.standby); + const mediaState = stringValue(status.state) || (inStandby === undefined ? 'unknown' : inStandby ? 'off' : 'on'); + const station = stringValue(currentService.station); + const seriesTitle = stringValue(currentService.name); + const volume = numberValue(status.volume); + const entities: ISimpleLocalEntitySnapshot[] = [{ + id: 'media_player', + uniqueId: `${enigma2Profile.domain}_${SimpleLocalMapper.slug(mac || host || name)}_media_player`, + name, + platform: 'media_player', + state: mediaState, + available: configArg.online ?? true, + writable: true, + attributes: { + mediaTitle: station || seriesTitle, + mediaSeriesTitle: seriesTitle, + mediaChannel: station, + mediaContentId: stringValue(currentService.serviceref) || stringValue(currentService.serviceRef), + mediaDescription: stringValue(currentService.fulldescription) || stringValue(currentService.description), + mediaStartTime: currentService.begin, + mediaEndTime: currentService.end, + mediaCurrentlyRecording: booleanValue(status.is_recording ?? status.isRecording), + isVolumeMuted: booleanValue(status.muted), + volumeLevel: volume === undefined ? undefined : volume / 100, + sourceList: arrayValue(status.source_list) || arrayValue(status.sourceList) || arrayValue(rawDataArg.source_list) || arrayValue(rawDataArg.sourceList), + sourceBouquet: configArg.sourceBouquet || configArg.source_bouquet, + useChannelIcon: configArg.useChannelIcon ?? configArg.use_channel_icon, + deepStandby: configArg.deepStandby ?? configArg.deep_standby, + }, + }]; + + return { + device: { + id: configArg.uniqueId || mac || (host ? `${host}:${port || ''}` : undefined) || name, + name, + manufacturer: stringValue(info.brand) || enigma2Profile.manufacturer, + model: stringValue(info.model) || enigma2Profile.model, + serialNumber: mac, + host, + port, + protocol: configArg.useTls || configArg.ssl ? 'https' : enigma2Profile.defaultProtocol, + attributes: { + mac, + verifySsl: configArg.verifySsl ?? configArg.verify_ssl, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; + +const firstRecord = (valueArg: unknown): Record | undefined => Array.isArray(valueArg) ? valueArg.find(isRecord) : undefined; + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; + +const booleanValue = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['1', 'true', 'yes', 'on', 'standby'].includes(value)) { + return true; + } + if (['0', 'false', 'no', 'off', 'active'].includes(value)) { + return false; + } + return undefined; +}; + +const arrayValue = (valueArg: unknown): unknown[] | undefined => Array.isArray(valueArg) ? valueArg : undefined; diff --git a/ts/integrations/enigma2/enigma2.types.ts b/ts/integrations/enigma2/enigma2.types.ts index 39a2c36..2ce96d6 100644 --- a/ts/integrations/enigma2/enigma2.types.ts +++ b/ts/integrations/enigma2/enigma2.types.ts @@ -1,4 +1,110 @@ -export interface IHomeAssistantEnigma2Config { - // TODO: replace with the TypeScript-native config for enigma2. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const enigma2Domain = 'enigma2'; +export const enigma2DefaultName = 'Enigma2 Media Player'; +export const enigma2DefaultPort = 80; + +export type TEnigma2RawData = TSimpleLocalRawData; +export interface IEnigma2Snapshot extends ISimpleLocalSnapshot {} +export interface IEnigma2Config extends ISimpleLocalConfig { + ssl?: boolean; + verifySsl?: boolean; + verify_ssl?: boolean; + useChannelIcon?: boolean; + use_channel_icon?: boolean; + deepStandby?: boolean; + deep_standby?: boolean; + sourceBouquet?: string; + source_bouquet?: string; + macAddress?: string; + mac_address?: string; } +export interface IHomeAssistantEnigma2Config extends IEnigma2Config {} + +const enigma2ControlServices = [ + 'turn_on', + 'turn_off', + 'volume_set', + 'volume_up', + 'volume_down', + 'volume_mute', + 'media_stop', + 'media_play', + 'media_pause', + 'media_next_track', + 'media_previous_track', + 'select_source', +]; + +export const enigma2Profile: ISimpleLocalIntegrationProfile = { + domain: 'enigma2', + displayName: 'Enigma2 (OpenWebif)', + manufacturer: 'Enigma2', + model: 'OpenWebif receiver', + defaultName: enigma2DefaultName, + defaultPort: enigma2DefaultPort, + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'media_player', + ], + serviceDomains: [ + 'media_player', + ], + controlServices: enigma2ControlServices, + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'enigma2', + 'openwebif', + 'dreambox', + 'receiver', + 'media player', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/enigma2', + upstreamDomain: 'enigma2', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'openwebifpy==4.3.1', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@autinerd', + ], + configFlow: true, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...enigma2ControlServices, + ], + platforms: [ + 'media_player', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local OpenWebif host setup with optional credentials and TLS flags', + 'OpenWebif about/status raw data mapping for media player snapshots', + 'snapshot, rawData, snapshotProvider, and injected native client operation', + 'commandExecutor-backed media player controls only when a real executor is injected', + ], + explicitUnsupported: [ + 'claiming live media player command success without injected client.execute or commandExecutor', + 'direct OpenWebif control calls from this package without an injected native client', + 'automatic bouquet option discovery without an injected native client snapshotProvider', + ], + }, + }, +}; diff --git a/ts/integrations/enigma2/index.ts b/ts/integrations/enigma2/index.ts index 11f47b0..12e0dba 100644 --- a/ts/integrations/enigma2/index.ts +++ b/ts/integrations/enigma2/index.ts @@ -1,2 +1,6 @@ +export * from './enigma2.classes.client.js'; +export * from './enigma2.classes.configflow.js'; export * from './enigma2.classes.integration.js'; +export * from './enigma2.discovery.js'; +export * from './enigma2.mapper.js'; export * from './enigma2.types.js'; diff --git a/ts/integrations/enocean/.generated-by-smarthome-exchange b/ts/integrations/enocean/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/enocean/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/enocean/enocean.classes.client.ts b/ts/integrations/enocean/enocean.classes.client.ts new file mode 100644 index 0000000..bda6af8 --- /dev/null +++ b/ts/integrations/enocean/enocean.classes.client.ts @@ -0,0 +1,19 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EnoceanMapper } from './enocean.mapper.js'; +import type { IEnoceanConfig, IEnoceanSnapshot } from './enocean.types.js'; +import { enoceanProfile } from './enocean.types.js'; + +export class EnoceanClient extends SimpleLocalClient { + constructor(private readonly configArg: IEnoceanConfig) { + super(enoceanProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EnoceanMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EnoceanMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/enocean/enocean.classes.configflow.ts b/ts/integrations/enocean/enocean.classes.configflow.ts new file mode 100644 index 0000000..a942607 --- /dev/null +++ b/ts/integrations/enocean/enocean.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEnoceanConfig } from './enocean.types.js'; +import { enoceanProfile } from './enocean.types.js'; + +export class EnoceanConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(enoceanProfile); + } +} diff --git a/ts/integrations/enocean/enocean.classes.integration.ts b/ts/integrations/enocean/enocean.classes.integration.ts index 873fea4..0f0d776 100644 --- a/ts/integrations/enocean/enocean.classes.integration.ts +++ b/ts/integrations/enocean/enocean.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EnoceanClient } from './enocean.classes.client.js'; +import { EnoceanConfigFlow } from './enocean.classes.configflow.js'; +import { createEnoceanDiscoveryDescriptor } from './enocean.discovery.js'; +import type { IEnoceanConfig } from './enocean.types.js'; +import { enoceanDomain, enoceanProfile } from './enocean.types.js'; + +export class EnoceanIntegration extends SimpleLocalIntegration { + public readonly domain = enoceanDomain; + public readonly discoveryDescriptor = createEnoceanDiscoveryDescriptor(); + public readonly configFlow = new EnoceanConfigFlow(); -export class HomeAssistantEnoceanIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "enocean", - displayName: "EnOcean", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/enocean", - "upstreamDomain": "enocean", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "enocean-async==0.4.2" - ], - "dependencies": [ - "usb" - ], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(enoceanProfile); + } + + public async setup(configArg: IEnoceanConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(enoceanProfile, new EnoceanClient(configArg)); } } + +export class HomeAssistantEnoceanIntegration extends EnoceanIntegration {} diff --git a/ts/integrations/enocean/enocean.discovery.ts b/ts/integrations/enocean/enocean.discovery.ts new file mode 100644 index 0000000..bf95ec5 --- /dev/null +++ b/ts/integrations/enocean/enocean.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { enoceanProfile } from './enocean.types.js'; + +export const createEnoceanDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enoceanProfile); diff --git a/ts/integrations/enocean/enocean.mapper.ts b/ts/integrations/enocean/enocean.mapper.ts new file mode 100644 index 0000000..b8d7249 --- /dev/null +++ b/ts/integrations/enocean/enocean.mapper.ts @@ -0,0 +1,232 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEnoceanConfig } from './enocean.types.js'; +import { enoceanDefaultName, enoceanProfile } from './enocean.types.js'; + +export class EnoceanMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: enoceanProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEnoceanConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(enoceanProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(enoceanProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEnoceanConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) { + return rawDataArg; + } + + const gateway = recordValue(rawDataArg.gateway) || rawDataArg; + const records = recordsFrom(rawDataArg.telegrams ?? rawDataArg.devices ?? rawDataArg.sensors ?? rawDataArg.values); + if (!records.length) { + return rawDataArg; + } + + const devicePath = configArg.device || stringValue(rawDataArg.device) || stringValue(gateway.device) || configArg.host; + const name = configArg.name || stringValue(rawDataArg.name) || stringValue(gateway.name) || enoceanDefaultName; + const entities = records.map((recordArg) => this.entityFromRecord(recordArg, configArg, devicePath || name)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg)); + + return { + device: { + id: configArg.uniqueId || devicePath || name, + name, + manufacturer: stringValue(gateway.manufacturer) || enoceanProfile.manufacturer, + model: stringValue(gateway.model) || enoceanProfile.model, + serialNumber: stringValue(gateway.serialNumber) || stringValue(gateway.serial_number), + host: configArg.host, + protocol: enoceanProfile.defaultProtocol, + attributes: { + device: devicePath, + baseAddress: addressValue(gateway.baseAddress ?? gateway.base_address), + signalReceiveMessage: 'enocean.receive_message', + signalSendMessage: 'enocean.send_message', + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } + + private static entityFromRecord(recordArg: Record, configArg: IEnoceanConfig, uniqueBaseArg: string): ISimpleLocalEntitySnapshot | undefined { + const address = addressValue(recordArg.id ?? recordArg.dev_id ?? recordArg.deviceId ?? recordArg.address ?? recordArg.sender); + const id = stringValue(recordArg.entityId) || stringValue(recordArg.entity_id) || address || stringValue(recordArg.name); + if (!id) { + return undefined; + } + + const deviceClass = stringValue(recordArg.deviceClass) || stringValue(recordArg.device_class) || stringValue(recordArg.type) || configArg.deviceClass || configArg.device_class; + const platform = platformValue(recordArg.platform) || platformFromDeviceClass(deviceClass, recordArg.state); + const state = stateValue(recordArg, deviceClass); + + return { + id: `${platform}_${SimpleLocalMapper.slug(id)}`, + uniqueId: `${enoceanProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_${SimpleLocalMapper.slug(id)}`, + name: stringValue(recordArg.name) || titleCase(id), + platform, + state, + available: recordArg.available === undefined ? true : booleanValue(recordArg.available), + writable: platform === 'light' || platform === 'switch', + unit: unitForDeviceClass(deviceClass), + deviceClass: normalizedDeviceClass(deviceClass), + attributes: { + address, + channel: numberValue(recordArg.channel ?? configArg.channel), + eep: stringValue(recordArg.eep), + rorg: recordArg.rorg, + rawAction: recordArg.action, + which: recordArg.which, + onoff: recordArg.onoff, + }, + }; + } +} + +const stateValue = (recordArg: Record, deviceClassArg: string | undefined): unknown => { + const explicit = recordArg.state ?? recordArg.value ?? recordArg.native_value ?? recordArg.nativeValue; + const normalized = normalizedDeviceClass(deviceClassArg); + if (explicit !== undefined) { + if (['light', 'motion', 'opening', 'switch'].includes(normalized || '')) { + return booleanValue(explicit) ?? explicit; + } + return explicit; + } + + const telegramData = Array.isArray(recordArg.telegram_data) ? recordArg.telegram_data : Array.isArray(recordArg.telegramData) ? recordArg.telegramData : undefined; + if (telegramData && normalized === 'temperature') { + return numberValue(telegramData[2]); + } + if (telegramData && normalized === 'humidity') { + const value = numberValue(telegramData[1]); + return value === undefined ? undefined : Math.round((value * 100 / 250) * 10) / 10; + } + if (telegramData && normalized === 'power') { + return numberValue(telegramData[0]); + } + return booleanValue(recordArg.is_on ?? recordArg.isOn ?? recordArg.on) ?? null; +}; + +const recordsFrom = (valueArg: unknown): Array> => { + if (Array.isArray(valueArg)) { + return valueArg.filter(isRecord); + } + if (isRecord(valueArg)) { + return Object.entries(valueArg).flatMap(([keyArg, entryArg]) => isRecord(entryArg) ? [{ id: keyArg, ...entryArg }] : []); + } + return []; +}; + +const platformValue = (valueArg: unknown): TEntityPlatform | undefined => { + const value = stringValue(valueArg); + return value && ['binary_sensor', 'light', 'sensor', 'switch'].includes(value) ? value as TEntityPlatform : undefined; +}; + +const platformFromDeviceClass = (deviceClassArg: string | undefined, stateArg: unknown): TEntityPlatform => { + const value = normalizedDeviceClass(deviceClassArg); + if (value === 'switch') { + return 'switch'; + } + if (value === 'light') { + return 'light'; + } + if (value === 'opening' || value === 'motion') { + return 'binary_sensor'; + } + return typeof stateArg === 'boolean' ? 'binary_sensor' : 'sensor'; +}; + +const normalizedDeviceClass = (valueArg: unknown): string | undefined => { + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + const map: Record = { + humidity: 'humidity', + powersensor: 'power', + power: 'power', + temperature: 'temperature', + windowhandle: 'opening', + window_handle: 'opening', + switch: 'switch', + light: 'light', + contact: 'opening', + motion: 'motion', + }; + return map[value] || value; +}; + +const unitForDeviceClass = (valueArg: unknown): string | undefined => { + const value = normalizedDeviceClass(valueArg); + if (value === 'temperature') { + return 'C'; + } + if (value === 'humidity') { + return '%'; + } + if (value === 'power') { + return 'W'; + } + return undefined; +}; + +const addressValue = (valueArg: unknown): string | undefined => { + if (Array.isArray(valueArg)) { + return valueArg.map((partArg) => Number(partArg).toString(16).padStart(2, '0')).join('').toUpperCase(); + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return Math.round(valueArg).toString(16).toUpperCase(); + } + return stringValue(valueArg); +}; + +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; + +const booleanValue = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['1', 'true', 'yes', 'on', 'open', 'opened', 'active'].includes(value)) { + return true; + } + if (['0', 'false', 'no', 'off', 'closed', 'close', 'inactive'].includes(value)) { + return false; + } + return undefined; +}; + +const titleCase = (valueArg: string): string => valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase()); diff --git a/ts/integrations/enocean/enocean.types.ts b/ts/integrations/enocean/enocean.types.ts index 12c917b..50488b3 100644 --- a/ts/integrations/enocean/enocean.types.ts +++ b/ts/integrations/enocean/enocean.types.ts @@ -1,4 +1,111 @@ -export interface IHomeAssistantEnoceanConfig { - // TODO: replace with the TypeScript-native config for enocean. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const enoceanDomain = 'enocean'; +export const enoceanDefaultName = 'EnOcean Gateway'; + +export type TEnoceanRawData = TSimpleLocalRawData; +export interface IEnoceanSnapshot extends ISimpleLocalSnapshot {} +export interface IEnoceanConfig extends ISimpleLocalConfig { + device?: string; + id?: number[]; + senderId?: number[]; + sender_id?: number[]; + channel?: number; + deviceClass?: string; + device_class?: string; + minTemp?: number; + min_temp?: number; + maxTemp?: number; + max_temp?: number; + rangeFrom?: number; + range_from?: number; + rangeTo?: number; + range_to?: number; } +export interface IHomeAssistantEnoceanConfig extends IEnoceanConfig {} + +const enoceanControlServices = [ + 'turn_on', + 'turn_off', + 'toggle', + 'set_level', +]; + +export const enoceanProfile: ISimpleLocalIntegrationProfile = { + domain: 'enocean', + displayName: 'EnOcean', + manufacturer: 'EnOcean', + model: 'USB 300 Gateway', + defaultName: enoceanDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'binary_sensor', + 'light', + 'sensor', + 'switch', + ], + serviceDomains: [ + 'light', + 'switch', + ], + controlServices: enoceanControlServices, + discoverySources: [ + 'manual', + 'usb', + 'custom', + ], + discoveryKeywords: [ + 'enocean', + 'usb 300', + 'erp1', + 'dongle', + 'gateway', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/enocean', + upstreamDomain: 'enocean', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: undefined, + requirements: [ + 'enocean-async==0.4.2', + ], + dependencies: [ + 'usb', + ], + afterDependencies: [], + codeowners: [], + configFlow: true, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...enoceanControlServices, + ], + platforms: [ + 'binary_sensor', + 'light', + 'sensor', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual USB dongle path setup and USB discovery record matching', + 'EnOcean raw gateway/device/telegram snapshot mapping for sensors, binary sensors, switches, and lights', + 'snapshot, rawData, snapshotProvider, and injected native client operation', + 'commandExecutor-backed gateway sends only when a real executor is injected', + ], + explicitUnsupported: [ + 'claiming live EnOcean send command success without injected client.execute or commandExecutor', + 'opening and managing a serial Gateway event loop inside this package runtime', + 'decoding every EEP beyond values represented in supplied snapshots/rawData', + ], + }, + }, +}; diff --git a/ts/integrations/enocean/index.ts b/ts/integrations/enocean/index.ts index a35ed9f..c9fe14b 100644 --- a/ts/integrations/enocean/index.ts +++ b/ts/integrations/enocean/index.ts @@ -1,2 +1,6 @@ +export * from './enocean.classes.client.js'; +export * from './enocean.classes.configflow.js'; export * from './enocean.classes.integration.js'; +export * from './enocean.discovery.js'; +export * from './enocean.mapper.js'; export * from './enocean.types.js'; diff --git a/ts/integrations/enphase_envoy/.generated-by-smarthome-exchange b/ts/integrations/enphase_envoy/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/enphase_envoy/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/enphase_envoy/enphase_envoy.classes.client.ts b/ts/integrations/enphase_envoy/enphase_envoy.classes.client.ts new file mode 100644 index 0000000..6bac2e6 --- /dev/null +++ b/ts/integrations/enphase_envoy/enphase_envoy.classes.client.ts @@ -0,0 +1,23 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { EnphaseEnvoyMapper } from './enphase_envoy.mapper.js'; +import type { IEnphaseEnvoyConfig, IEnphaseEnvoySnapshot } from './enphase_envoy.types.js'; +import { enphaseEnvoyProfile } from './enphase_envoy.types.js'; + +export class EnphaseEnvoyClient extends SimpleLocalClient { + private readonly configArg: IEnphaseEnvoyConfig; + + constructor(configArg: IEnphaseEnvoyConfig) { + const runtimeConfig = configArg.rawData !== undefined || configArg.entities?.length || configArg.snapshot ? { ...configArg, host: undefined, path: undefined, transport: 'snapshot' as const } : configArg; + super(enphaseEnvoyProfile, runtimeConfig); + this.configArg = configArg; + } + + public async getSnapshot(forceRefreshArg = false): Promise { + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return EnphaseEnvoyMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return EnphaseEnvoyMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/enphase_envoy/enphase_envoy.classes.configflow.ts b/ts/integrations/enphase_envoy/enphase_envoy.classes.configflow.ts new file mode 100644 index 0000000..5e1dc5c --- /dev/null +++ b/ts/integrations/enphase_envoy/enphase_envoy.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js'; +import { enphaseEnvoyProfile } from './enphase_envoy.types.js'; + +export class EnphaseEnvoyConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(enphaseEnvoyProfile); + } +} diff --git a/ts/integrations/enphase_envoy/enphase_envoy.classes.integration.ts b/ts/integrations/enphase_envoy/enphase_envoy.classes.integration.ts index 9bc90d6..0f7c2f2 100644 --- a/ts/integrations/enphase_envoy/enphase_envoy.classes.integration.ts +++ b/ts/integrations/enphase_envoy/enphase_envoy.classes.integration.ts @@ -1,29 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { EnphaseEnvoyClient } from './enphase_envoy.classes.client.js'; +import { EnphaseEnvoyConfigFlow } from './enphase_envoy.classes.configflow.js'; +import { createEnphaseEnvoyDiscoveryDescriptor } from './enphase_envoy.discovery.js'; +import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js'; +import { enphaseEnvoyDomain, enphaseEnvoyProfile } from './enphase_envoy.types.js'; + +export class EnphaseEnvoyIntegration extends SimpleLocalIntegration { + public readonly domain = enphaseEnvoyDomain; + public readonly discoveryDescriptor = createEnphaseEnvoyDiscoveryDescriptor(); + public readonly configFlow = new EnphaseEnvoyConfigFlow(); -export class HomeAssistantEnphaseEnvoyIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "enphase_envoy", - displayName: "Enphase Envoy", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/enphase_envoy", - "upstreamDomain": "enphase_envoy", - "integrationType": "hub", - "iotClass": "local_polling", - "qualityScale": "platinum", - "requirements": [ - "pyenphase==2.4.8" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@bdraco", - "@cgarwood", - "@catsmanac" - ] -}, - }); + super(enphaseEnvoyProfile); + } + + public async setup(configArg: IEnphaseEnvoyConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(enphaseEnvoyProfile, new EnphaseEnvoyClient(configArg)); } } + +export class HomeAssistantEnphaseEnvoyIntegration extends EnphaseEnvoyIntegration {} diff --git a/ts/integrations/enphase_envoy/enphase_envoy.discovery.ts b/ts/integrations/enphase_envoy/enphase_envoy.discovery.ts new file mode 100644 index 0000000..068a3d7 --- /dev/null +++ b/ts/integrations/enphase_envoy/enphase_envoy.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { enphaseEnvoyProfile } from './enphase_envoy.types.js'; + +export const createEnphaseEnvoyDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enphaseEnvoyProfile); diff --git a/ts/integrations/enphase_envoy/enphase_envoy.mapper.ts b/ts/integrations/enphase_envoy/enphase_envoy.mapper.ts new file mode 100644 index 0000000..304691c --- /dev/null +++ b/ts/integrations/enphase_envoy/enphase_envoy.mapper.ts @@ -0,0 +1,283 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js'; +import { enphaseEnvoyDefaultName, enphaseEnvoyDefaultPort, enphaseEnvoyProfile } from './enphase_envoy.types.js'; + +export class EnphaseEnvoyMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: enphaseEnvoyProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IEnphaseEnvoyConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(enphaseEnvoyProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(enphaseEnvoyProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IEnphaseEnvoyConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) { + return rawDataArg; + } + + const envoyData = recordValue(rawDataArg.envoy_data) || recordValue(rawDataArg.envoyData) || recordValue(rawDataArg.data) || rawDataArg; + const hasEnvoyData = Boolean(envoyData.system_production || envoyData.systemProduction || envoyData.production || envoyData.inverters || envoyData.encharge_aggregate || envoyData.enpower || envoyData.dry_contact_status || envoyData.tariff); + if (!hasEnvoyData) { + return rawDataArg; + } + + const host = configArg.host || stringValue(rawDataArg.host) || stringValue(envoyData.host); + const port = configArg.port || numberValue(rawDataArg.port) || (host ? enphaseEnvoyDefaultPort : undefined); + const serial = configArg.serial || configArg.uniqueId || stringValue(rawDataArg.serialNumber) || stringValue(rawDataArg.serial_number) || stringValue(envoyData.serialNumber) || stringValue(envoyData.serial_number); + const name = configArg.name || stringValue(rawDataArg.name) || stringValue(envoyData.name) || (serial ? `${enphaseEnvoyDefaultName} ${serial}` : enphaseEnvoyDefaultName); + const entities = this.entitiesFromEnvoyData(envoyData, serial || host || name); + + return { + device: { + id: configArg.uniqueId || serial || (host ? `${host}:${port || ''}` : undefined) || name, + name, + manufacturer: enphaseEnvoyProfile.manufacturer, + model: stringValue(rawDataArg.envoyModel) || stringValue(rawDataArg.envoy_model) || stringValue(envoyData.envoyModel) || stringValue(envoyData.envoy_model) || enphaseEnvoyProfile.model, + serialNumber: serial, + host, + port, + protocol: configArg.useTls ? 'https' : enphaseEnvoyProfile.defaultProtocol, + attributes: { + firmware: stringValue(rawDataArg.firmware) || stringValue(envoyData.firmware), + partNumber: stringValue(rawDataArg.partNumber) || stringValue(rawDataArg.part_number) || stringValue(envoyData.partNumber) || stringValue(envoyData.part_number), + disableKeepAlive: configArg.disableKeepAlive ?? configArg.disable_keep_alive, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } + + private static entitiesFromEnvoyData(envoyDataArg: Record, uniqueBaseArg: string): ISimpleLocalEntitySnapshot[] { + const entities: ISimpleLocalEntitySnapshot[] = []; + const production = recordValue(envoyDataArg.system_production) || recordValue(envoyDataArg.systemProduction) || recordValue(envoyDataArg.production); + const consumption = recordValue(envoyDataArg.system_consumption) || recordValue(envoyDataArg.systemConsumption) || recordValue(envoyDataArg.consumption); + const netConsumption = recordValue(envoyDataArg.system_net_consumption) || recordValue(envoyDataArg.systemNetConsumption) || recordValue(envoyDataArg.net_consumption); + const enchargeAggregate = recordValue(envoyDataArg.encharge_aggregate) || recordValue(envoyDataArg.enchargeAggregate) || recordValue(envoyDataArg.battery_aggregate) || recordValue(envoyDataArg.batteryAggregate); + const enpower = recordValue(envoyDataArg.enpower); + const tariff = recordValue(envoyDataArg.tariff); + const storageSettings = recordValue(tariff?.storage_settings) || recordValue(tariff?.storageSettings) || recordValue(envoyDataArg.storage_settings) || recordValue(envoyDataArg.storageSettings); + + this.addSystemPowerEntities(entities, uniqueBaseArg, production, 'production', 'Production'); + this.addSystemPowerEntities(entities, uniqueBaseArg, consumption, 'consumption', 'Consumption'); + this.addSystemPowerEntities(entities, uniqueBaseArg, netConsumption, 'net_consumption', 'Net Consumption'); + + for (const inverter of recordsFrom(envoyDataArg.inverters)) { + const serial = stringValue(inverter.serialNumber) || stringValue(inverter.serial_number) || stringValue(inverter.id); + const watts = numberValue(inverter.last_report_watts ?? inverter.lastReportWatts ?? inverter.watts); + if (serial && watts !== undefined) { + entities.push(sensorEntity(uniqueBaseArg, `inverter_${serial}_power`, `Inverter ${serial} Power`, watts, 'W', 'power', 'measurement', { serialNumber: serial })); + } + } + + const batteryLevel = numberValue(enchargeAggregate?.state_of_charge ?? enchargeAggregate?.stateOfCharge ?? enchargeAggregate?.soc); + if (batteryLevel !== undefined) { + entities.push(sensorEntity(uniqueBaseArg, 'battery_level', 'Battery Level', batteryLevel, '%', 'battery', 'measurement')); + } + const reserveSoc = numberValue(enchargeAggregate?.reserve_state_of_charge ?? enchargeAggregate?.reserveStateOfCharge ?? storageSettings?.reserved_soc ?? storageSettings?.reservedSoc); + if (reserveSoc !== undefined) { + entities.push({ + ...sensorEntity(uniqueBaseArg, 'reserve_soc', 'Reserve Battery Level', reserveSoc, '%', 'battery', 'measurement'), + platform: 'number', + writable: true, + }); + } + + const gridClosed = booleanFromClosed(enpower?.mains_oper_state ?? enpower?.mainsOperState ?? enpower?.grid_status ?? enpower?.gridStatus); + if (gridClosed !== undefined) { + entities.push({ + id: 'grid_status', + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_grid_status`, + name: 'Grid Status', + platform: 'binary_sensor', + state: gridClosed, + available: true, + writable: false, + deviceClass: 'connectivity', + attributes: { + serialNumber: stringValue(enpower?.serial_number) || stringValue(enpower?.serialNumber), + rawState: enpower?.mains_oper_state ?? enpower?.mainsOperState, + }, + }); + } + + const gridEnabled = booleanFromClosed(enpower?.mains_admin_state ?? enpower?.mainsAdminState); + if (gridEnabled !== undefined) { + entities.push({ + id: 'grid_enabled', + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_grid_enabled`, + name: 'Grid Enabled', + platform: 'switch', + state: gridEnabled, + available: true, + writable: true, + attributes: { + serialNumber: stringValue(enpower?.serial_number) || stringValue(enpower?.serialNumber), + serviceDomain: 'switch', + }, + }); + } + + for (const relay of recordsFrom(envoyDataArg.dry_contact_status ?? envoyDataArg.dryContactStatus)) { + const relayId = stringValue(relay.id) || stringValue(relay.relay_id) || stringValue(relay.relayId); + if (!relayId) { + continue; + } + entities.push({ + id: `relay_${SimpleLocalMapper.slug(relayId)}`, + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_relay_${SimpleLocalMapper.slug(relayId)}`, + name: stringValue(relay.name) || `Relay ${relayId}`, + platform: 'switch', + state: booleanFromClosed(relay.status ?? relay.state), + available: true, + writable: true, + attributes: { + relayId, + }, + }); + } + + const chargeFromGrid = booleanValue(storageSettings?.charge_from_grid ?? storageSettings?.chargeFromGrid); + if (chargeFromGrid !== undefined) { + entities.push({ + id: 'charge_from_grid', + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_charge_from_grid`, + name: 'Charge From Grid', + platform: 'switch', + state: chargeFromGrid, + available: true, + writable: true, + }); + } + + const storageMode = stringValue(storageSettings?.mode ?? storageSettings?.storage_mode ?? storageSettings?.storageMode); + if (storageMode) { + entities.push({ + id: 'storage_mode', + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_storage_mode`, + name: 'Storage Mode', + platform: 'select', + state: storageMode.toLowerCase(), + available: true, + writable: true, + attributes: { + options: ['backup', 'self_consumption', 'savings'], + }, + }); + } + + return entities; + } + + private static addSystemPowerEntities(entitiesArg: ISimpleLocalEntitySnapshot[], uniqueBaseArg: string, dataArg: Record | undefined, keyArg: string, labelArg: string): void { + if (!dataArg) { + return; + } + const wattsNow = numberValue(dataArg.watts_now ?? dataArg.wattsNow ?? dataArg.current_power ?? dataArg.currentPower); + if (wattsNow !== undefined) { + entitiesArg.push(sensorEntity(uniqueBaseArg, keyArg, `Current Power ${labelArg}`, wattsNow, 'W', 'power', 'measurement')); + } + const today = numberValue(dataArg.watt_hours_today ?? dataArg.wattHoursToday ?? dataArg.energy_today ?? dataArg.energyToday); + if (today !== undefined) { + entitiesArg.push(sensorEntity(uniqueBaseArg, `daily_${keyArg}`, `${labelArg} Today`, today, 'Wh', 'energy', 'total_increasing')); + } + const lifetime = numberValue(dataArg.watt_hours_lifetime ?? dataArg.wattHoursLifetime ?? dataArg.lifetime_energy ?? dataArg.lifetimeEnergy); + if (lifetime !== undefined) { + entitiesArg.push(sensorEntity(uniqueBaseArg, `lifetime_${keyArg}`, `Lifetime ${labelArg}`, lifetime, 'Wh', 'energy', 'total_increasing')); + } + } +} + +const sensorEntity = (uniqueBaseArg: string, idArg: string, nameArg: string, stateArg: unknown, unitArg?: string, deviceClassArg?: string, stateClassArg?: string, attributesArg?: Record): ISimpleLocalEntitySnapshot => ({ + id: SimpleLocalMapper.slug(idArg), + uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_${SimpleLocalMapper.slug(idArg)}`, + name: nameArg, + platform: 'sensor', + state: stateArg, + available: true, + writable: false, + unit: unitArg, + deviceClass: deviceClassArg, + stateClass: stateClassArg, + attributes: attributesArg, +}); + +const recordsFrom = (valueArg: unknown): Array> => { + if (Array.isArray(valueArg)) { + return valueArg.filter(isRecord); + } + if (isRecord(valueArg)) { + return Object.entries(valueArg).flatMap(([keyArg, entryArg]) => isRecord(entryArg) ? [{ id: keyArg, ...entryArg }] : []); + } + return []; +}; + +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; + +const booleanValue = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['1', 'true', 'yes', 'on', 'enabled', 'closed'].includes(value)) { + return true; + } + if (['0', 'false', 'no', 'off', 'disabled', 'open'].includes(value)) { + return false; + } + return undefined; +}; + +const booleanFromClosed = (valueArg: unknown): boolean | undefined => { + const explicit = booleanValue(valueArg); + if (explicit !== undefined) { + return explicit; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['closed', 'close', 'on_grid', 'connected'].includes(value)) { + return true; + } + if (['open', 'opened', 'off_grid', 'disconnected'].includes(value)) { + return false; + } + return undefined; +}; diff --git a/ts/integrations/enphase_envoy/enphase_envoy.types.ts b/ts/integrations/enphase_envoy/enphase_envoy.types.ts index 9a64b7f..4901e43 100644 --- a/ts/integrations/enphase_envoy/enphase_envoy.types.ts +++ b/ts/integrations/enphase_envoy/enphase_envoy.types.ts @@ -1,4 +1,113 @@ -export interface IHomeAssistantEnphaseEnvoyConfig { - // TODO: replace with the TypeScript-native config for enphase_envoy. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const enphaseEnvoyDomain = 'enphase_envoy'; +export const enphaseEnvoyDefaultName = 'Envoy'; +export const enphaseEnvoyDefaultPort = 80; + +export type TEnphaseEnvoyRawData = TSimpleLocalRawData; +export interface IEnphaseEnvoySnapshot extends ISimpleLocalSnapshot {} +export interface IEnphaseEnvoyConfig extends ISimpleLocalConfig { + serial?: string; + diagnosticsIncludeFixtures?: boolean; + diagnostics_include_fixtures?: boolean; + disableKeepAlive?: boolean; + disable_keep_alive?: boolean; } +export interface IHomeAssistantEnphaseEnvoyConfig extends IEnphaseEnvoyConfig {} + +const enphaseEnvoyControlServices = [ + 'turn_on', + 'turn_off', + 'toggle', + 'set_value', + 'select_option', +]; + +export const enphaseEnvoyProfile: ISimpleLocalIntegrationProfile = { + domain: 'enphase_envoy', + displayName: 'Enphase Envoy', + manufacturer: 'Enphase', + model: 'Envoy', + defaultName: enphaseEnvoyDefaultName, + defaultPort: enphaseEnvoyDefaultPort, + defaultHttpPath: '/production.json?details=1', + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'binary_sensor', + 'number', + 'select', + 'sensor', + 'switch', + ], + serviceDomains: [ + 'number', + 'select', + 'switch', + ], + controlServices: enphaseEnvoyControlServices, + discoverySources: [ + 'manual', + 'mdns', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'enphase', + 'envoy', + '_enphase-envoy._tcp.local.', + 'solar', + 'inverter', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/enphase_envoy', + upstreamDomain: 'enphase_envoy', + integrationType: 'hub', + iotClass: 'local_polling', + qualityScale: 'platinum', + qualityScaleRulesPath: 'homeassistant/components/enphase_envoy/quality_scale.yaml', + requirements: [ + 'pyenphase==2.4.8', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@bdraco', + '@cgarwood', + '@catsmanac', + ], + configFlow: true, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...enphaseEnvoyControlServices, + ], + platforms: [ + 'binary_sensor', + 'number', + 'select', + 'sensor', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual and mDNS local Envoy host setup with optional credentials/token data', + 'generic read-only HTTP production endpoint snapshots when config.path is reachable locally', + 'pyenphase-style raw data mapping for production, consumption, inverters, batteries, grid, relays, and storage settings', + 'snapshot, rawData, snapshotProvider, and injected native client operation', + 'commandExecutor-backed switch, number, and select controls only when a real executor is injected', + ], + explicitUnsupported: [ + 'claiming live grid, relay, reserve SOC, or storage mode command success without injected client.execute or commandExecutor', + 'performing Enphase cloud authentication, token refresh, or firmware scheduling inside this package runtime', + 'collecting Home Assistant diagnostic fixture endpoint bundles directly from this package runtime', + ], + }, + }, +}; diff --git a/ts/integrations/enphase_envoy/index.ts b/ts/integrations/enphase_envoy/index.ts index 1911c9d..f845f96 100644 --- a/ts/integrations/enphase_envoy/index.ts +++ b/ts/integrations/enphase_envoy/index.ts @@ -1,2 +1,6 @@ +export * from './enphase_envoy.classes.client.js'; +export * from './enphase_envoy.classes.configflow.js'; export * from './enphase_envoy.classes.integration.js'; +export * from './enphase_envoy.discovery.js'; +export * from './enphase_envoy.mapper.js'; export * from './enphase_envoy.types.js'; diff --git a/ts/integrations/envisalink/.generated-by-smarthome-exchange b/ts/integrations/envisalink/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/envisalink/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/envisalink/envisalink.classes.client.ts b/ts/integrations/envisalink/envisalink.classes.client.ts new file mode 100644 index 0000000..ad61c79 --- /dev/null +++ b/ts/integrations/envisalink/envisalink.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEnvisalinkConfig } from './envisalink.types.js'; +import { envisalinkProfile } from './envisalink.types.js'; + +export class EnvisalinkClient extends SimpleLocalClient { + constructor(configArg: IEnvisalinkConfig) { + super(envisalinkProfile, configArg); + } +} diff --git a/ts/integrations/envisalink/envisalink.classes.configflow.ts b/ts/integrations/envisalink/envisalink.classes.configflow.ts new file mode 100644 index 0000000..3b3f380 --- /dev/null +++ b/ts/integrations/envisalink/envisalink.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEnvisalinkConfig } from './envisalink.types.js'; +import { envisalinkProfile } from './envisalink.types.js'; + +export class EnvisalinkConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(envisalinkProfile); + } +} diff --git a/ts/integrations/envisalink/envisalink.classes.integration.ts b/ts/integrations/envisalink/envisalink.classes.integration.ts index c65a5b2..4763d25 100644 --- a/ts/integrations/envisalink/envisalink.classes.integration.ts +++ b/ts/integrations/envisalink/envisalink.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EnvisalinkConfigFlow } from './envisalink.classes.configflow.js'; +import { createEnvisalinkDiscoveryDescriptor } from './envisalink.discovery.js'; +import type { IEnvisalinkConfig } from './envisalink.types.js'; +import { envisalinkDomain, envisalinkProfile } from './envisalink.types.js'; + +export class EnvisalinkIntegration extends SimpleLocalIntegration { + public readonly domain = envisalinkDomain; + public readonly discoveryDescriptor = createEnvisalinkDiscoveryDescriptor(); + public readonly configFlow = new EnvisalinkConfigFlow(); -export class HomeAssistantEnvisalinkIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "envisalink", - displayName: "Envisalink", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/envisalink", - "upstreamDomain": "envisalink", - "iotClass": "local_push", - "qualityScale": "legacy", - "requirements": [ - "pyenvisalink==4.7" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(envisalinkProfile); } } + +export class HomeAssistantEnvisalinkIntegration extends EnvisalinkIntegration {} diff --git a/ts/integrations/envisalink/envisalink.discovery.ts b/ts/integrations/envisalink/envisalink.discovery.ts new file mode 100644 index 0000000..c6d6d87 --- /dev/null +++ b/ts/integrations/envisalink/envisalink.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { envisalinkProfile } from './envisalink.types.js'; + +export const createEnvisalinkDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(envisalinkProfile); diff --git a/ts/integrations/envisalink/envisalink.mapper.ts b/ts/integrations/envisalink/envisalink.mapper.ts new file mode 100644 index 0000000..73b9398 --- /dev/null +++ b/ts/integrations/envisalink/envisalink.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEnvisalinkConfig } from './envisalink.types.js'; +import { envisalinkProfile } from './envisalink.types.js'; + +export class EnvisalinkMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: envisalinkProfile }); + } + + public static toSnapshotFromRaw(configArg: IEnvisalinkConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: envisalinkProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(envisalinkProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(envisalinkProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/envisalink/envisalink.types.ts b/ts/integrations/envisalink/envisalink.types.ts index 9339984..6661c77 100644 --- a/ts/integrations/envisalink/envisalink.types.ts +++ b/ts/integrations/envisalink/envisalink.types.ts @@ -1,4 +1,122 @@ -export interface IHomeAssistantEnvisalinkConfig { - // TODO: replace with the TypeScript-native config for envisalink. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const envisalinkDomain = 'envisalink'; +export const envisalinkDefaultName = 'Envisalink'; +export const envisalinkDefaultPort = 4025; + +export type TEnvisalinkPanelType = 'HONEYWELL' | 'DSC' | string; +export type TEnvisalinkRawData = TSimpleLocalRawData; +export interface IEnvisalinkSnapshot extends ISimpleLocalSnapshot {} +export interface IEnvisalinkZoneConfig { + name: string; + type?: string; } +export interface IEnvisalinkPartitionConfig { + name: string; +} +export interface IEnvisalinkConfig extends ISimpleLocalConfig { + panelType?: TEnvisalinkPanelType; + evlVersion?: number; + code?: string; + panicType?: string; + keepaliveInterval?: number; + zonedumpInterval?: number; + zones?: Record; + partitions?: Record; +} +export interface IHomeAssistantEnvisalinkConfig extends IEnvisalinkConfig {} + +const envisalinkControlServices = [ + 'alarm_keypress', + 'invoke_custom_function', + 'alarm_disarm', + 'alarm_arm_home', + 'alarm_arm_away', + 'alarm_arm_night', + 'alarm_trigger', + 'turn_on', + 'turn_off', +]; + +export const envisalinkProfile: ISimpleLocalIntegrationProfile = { + domain: envisalinkDomain, + displayName: 'Envisalink', + manufacturer: 'EyezOn', + model: 'Envisalink', + defaultName: envisalinkDefaultName, + defaultPort: envisalinkDefaultPort, + defaultProtocol: 'tcp', + status: 'control-runtime', + platforms: [ + 'binary_sensor', + 'sensor', + 'switch', + ], + serviceDomains: [ + 'alarm_control_panel', + 'switch', + 'envisalink', + ], + controlServices: envisalinkControlServices, + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'envisalink', + 'evl', + 'eyezon', + 'honeywell', + 'dsc', + 'alarm', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/envisalink', + upstreamDomain: 'envisalink', + iotClass: 'local_push', + qualityScale: 'legacy', + requirements: [ + 'pyenvisalink==4.7', + ], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: false, + documentation: 'https://www.home-assistant.io/integrations/envisalink', + haPlatforms: [ + 'alarm_control_panel', + 'binary_sensor', + 'sensor', + 'switch', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...envisalinkControlServices, + ], + platforms: [ + 'alarm_control_panel', + 'binary_sensor', + 'sensor', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local endpoint setup for Envisalink host, port, panel metadata, snapshots, raw data, snapshotProvider, and injected native clients', + 'TCP endpoint metadata for the documented Envisalink port without claiming protocol login support', + 'executor-gated alarm, keypress, custom function, and bypass switch service dispatch', + ], + explicitUnsupported: [ + 'claiming alarm or switch command success without injected client.execute or commandExecutor', + 'full pyenvisalink login, callback, and keypad session emulation without an injected native client', + 'YAML-only Home Assistant setup semantics beyond the local-first config flow wrapper', + ], + }, + }, +}; diff --git a/ts/integrations/envisalink/index.ts b/ts/integrations/envisalink/index.ts index 959a393..c9c49a8 100644 --- a/ts/integrations/envisalink/index.ts +++ b/ts/integrations/envisalink/index.ts @@ -1,2 +1,6 @@ +export * from './envisalink.classes.client.js'; +export * from './envisalink.classes.configflow.js'; export * from './envisalink.classes.integration.js'; +export * from './envisalink.discovery.js'; +export * from './envisalink.mapper.js'; export * from './envisalink.types.js'; diff --git a/ts/integrations/ephember/.generated-by-smarthome-exchange b/ts/integrations/ephember/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/ephember/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/ephember/ephember.classes.client.ts b/ts/integrations/ephember/ephember.classes.client.ts new file mode 100644 index 0000000..5a2d99f --- /dev/null +++ b/ts/integrations/ephember/ephember.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEphemberConfig } from './ephember.types.js'; +import { ephemberProfile } from './ephember.types.js'; + +export class EphemberClient extends SimpleLocalClient { + constructor(configArg: IEphemberConfig) { + super(ephemberProfile, configArg); + } +} diff --git a/ts/integrations/ephember/ephember.classes.configflow.ts b/ts/integrations/ephember/ephember.classes.configflow.ts new file mode 100644 index 0000000..31d37a1 --- /dev/null +++ b/ts/integrations/ephember/ephember.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEphemberConfig } from './ephember.types.js'; +import { ephemberProfile } from './ephember.types.js'; + +export class EphemberConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(ephemberProfile); + } +} diff --git a/ts/integrations/ephember/ephember.classes.integration.ts b/ts/integrations/ephember/ephember.classes.integration.ts index 714e495..bf8b60d 100644 --- a/ts/integrations/ephember/ephember.classes.integration.ts +++ b/ts/integrations/ephember/ephember.classes.integration.ts @@ -1,27 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EphemberConfigFlow } from './ephember.classes.configflow.js'; +import { createEphemberDiscoveryDescriptor } from './ephember.discovery.js'; +import type { IEphemberConfig } from './ephember.types.js'; +import { ephemberDomain, ephemberProfile } from './ephember.types.js'; + +export class EphemberIntegration extends SimpleLocalIntegration { + public readonly domain = ephemberDomain; + public readonly discoveryDescriptor = createEphemberDiscoveryDescriptor(); + public readonly configFlow = new EphemberConfigFlow(); -export class HomeAssistantEphemberIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "ephember", - displayName: "EPH Controls", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/ephember", - "upstreamDomain": "ephember", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "pyephember2==0.4.12" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@ttroy50", - "@roberty99" - ] -}, - }); + super(ephemberProfile); } } + +export class HomeAssistantEphemberIntegration extends EphemberIntegration {} diff --git a/ts/integrations/ephember/ephember.discovery.ts b/ts/integrations/ephember/ephember.discovery.ts new file mode 100644 index 0000000..9af5139 --- /dev/null +++ b/ts/integrations/ephember/ephember.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { ephemberProfile } from './ephember.types.js'; + +export const createEphemberDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(ephemberProfile); diff --git a/ts/integrations/ephember/ephember.mapper.ts b/ts/integrations/ephember/ephember.mapper.ts new file mode 100644 index 0000000..fafac05 --- /dev/null +++ b/ts/integrations/ephember/ephember.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEphemberConfig } from './ephember.types.js'; +import { ephemberProfile } from './ephember.types.js'; + +export class EphemberMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: ephemberProfile }); + } + + public static toSnapshotFromRaw(configArg: IEphemberConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: ephemberProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(ephemberProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(ephemberProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/ephember/ephember.types.ts b/ts/integrations/ephember/ephember.types.ts index 874c2e3..8a1daa4 100644 --- a/ts/integrations/ephember/ephember.types.ts +++ b/ts/integrations/ephember/ephember.types.ts @@ -1,4 +1,93 @@ -export interface IHomeAssistantEphemberConfig { - // TODO: replace with the TypeScript-native config for ephember. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const ephemberDomain = 'ephember'; +export const ephemberDefaultName = 'EPH Controls'; + +export type TEphemberRawData = TSimpleLocalRawData; +export interface IEphemberSnapshot extends ISimpleLocalSnapshot {} +export interface IEphemberConfig extends ISimpleLocalConfig { + homes?: Array>; + zoneIds?: Array; } +export interface IHomeAssistantEphemberConfig extends IEphemberConfig {} + +const ephemberControlServices = [ + 'turn_on', + 'turn_off', + 'set_temperature', + 'set_hvac_mode', +]; + +export const ephemberProfile: ISimpleLocalIntegrationProfile = { + domain: ephemberDomain, + displayName: 'EPH Controls', + manufacturer: 'EPH Controls', + model: 'Ember', + defaultName: ephemberDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'climate', + ], + serviceDomains: [ + 'climate', + ], + controlServices: ephemberControlServices, + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'ephember', + 'eph', + 'ember', + 'thermostat', + 'pyephember2', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/ephember', + upstreamDomain: 'ephember', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [ + 'pyephember2==0.4.12', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@ttroy50', + '@roberty99', + ], + configFlow: false, + documentation: 'https://www.home-assistant.io/integrations/ephember', + haPlatforms: [ + 'climate', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...ephemberControlServices, + ], + platforms: [ + 'climate', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual EPH Controls setup with credentials or local data source metadata', + 'snapshot, raw data, snapshotProvider, and injected native EphEmber client operation', + 'executor-gated thermostat mode and target temperature service dispatch', + ], + explicitUnsupported: [ + 'claiming thermostat command success without injected client.execute or commandExecutor', + 'direct pyephember2 account session operation without an injected native client', + 'background polling of the upstream API without a supplied snapshotProvider or client', + ], + }, + }, +}; diff --git a/ts/integrations/ephember/index.ts b/ts/integrations/ephember/index.ts index 85413d3..e851f28 100644 --- a/ts/integrations/ephember/index.ts +++ b/ts/integrations/ephember/index.ts @@ -1,2 +1,6 @@ +export * from './ephember.classes.client.js'; +export * from './ephember.classes.configflow.js'; export * from './ephember.classes.integration.js'; +export * from './ephember.discovery.js'; +export * from './ephember.mapper.js'; export * from './ephember.types.js'; diff --git a/ts/integrations/epson/.generated-by-smarthome-exchange b/ts/integrations/epson/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/epson/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/epson/epson.classes.client.ts b/ts/integrations/epson/epson.classes.client.ts new file mode 100644 index 0000000..b4c45aa --- /dev/null +++ b/ts/integrations/epson/epson.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEpsonConfig } from './epson.types.js'; +import { epsonProfile } from './epson.types.js'; + +export class EpsonClient extends SimpleLocalClient { + constructor(configArg: IEpsonConfig) { + super(epsonProfile, configArg); + } +} diff --git a/ts/integrations/epson/epson.classes.configflow.ts b/ts/integrations/epson/epson.classes.configflow.ts new file mode 100644 index 0000000..1a0c973 --- /dev/null +++ b/ts/integrations/epson/epson.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEpsonConfig } from './epson.types.js'; +import { epsonProfile } from './epson.types.js'; + +export class EpsonConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(epsonProfile); + } +} diff --git a/ts/integrations/epson/epson.classes.integration.ts b/ts/integrations/epson/epson.classes.integration.ts index 9e27d7a..ac4c67f 100644 --- a/ts/integrations/epson/epson.classes.integration.ts +++ b/ts/integrations/epson/epson.classes.integration.ts @@ -1,26 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EpsonConfigFlow } from './epson.classes.configflow.js'; +import { createEpsonDiscoveryDescriptor } from './epson.discovery.js'; +import type { IEpsonConfig } from './epson.types.js'; +import { epsonDomain, epsonProfile } from './epson.types.js'; + +export class EpsonIntegration extends SimpleLocalIntegration { + public readonly domain = epsonDomain; + public readonly discoveryDescriptor = createEpsonDiscoveryDescriptor(); + public readonly configFlow = new EpsonConfigFlow(); -export class HomeAssistantEpsonIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "epson", - displayName: "Epson", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/epson", - "upstreamDomain": "epson", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "epson-projector==0.6.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@pszafer" - ] -}, - }); + super(epsonProfile); } } + +export class HomeAssistantEpsonIntegration extends EpsonIntegration {} diff --git a/ts/integrations/epson/epson.discovery.ts b/ts/integrations/epson/epson.discovery.ts new file mode 100644 index 0000000..82a3997 --- /dev/null +++ b/ts/integrations/epson/epson.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { epsonProfile } from './epson.types.js'; + +export const createEpsonDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(epsonProfile); diff --git a/ts/integrations/epson/epson.mapper.ts b/ts/integrations/epson/epson.mapper.ts new file mode 100644 index 0000000..4dfc5f6 --- /dev/null +++ b/ts/integrations/epson/epson.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEpsonConfig } from './epson.types.js'; +import { epsonProfile } from './epson.types.js'; + +export class EpsonMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: epsonProfile }); + } + + public static toSnapshotFromRaw(configArg: IEpsonConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: epsonProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(epsonProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(epsonProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/epson/epson.types.ts b/ts/integrations/epson/epson.types.ts index f1db3e6..046adf9 100644 --- a/ts/integrations/epson/epson.types.ts +++ b/ts/integrations/epson/epson.types.ts @@ -1,4 +1,109 @@ -export interface IHomeAssistantEpsonConfig { - // TODO: replace with the TypeScript-native config for epson. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const epsonDomain = 'epson'; +export const epsonDefaultName = 'Epson Projector'; + +export type TEpsonConnectionType = 'http' | 'serial' | string; +export type TEpsonRawData = TSimpleLocalRawData; +export interface IEpsonSnapshot extends ISimpleLocalSnapshot {} +export interface IEpsonConfig extends ISimpleLocalConfig { + connectionType?: TEpsonConnectionType; + serialNumber?: string; + source?: string; + cmode?: string; } +export interface IHomeAssistantEpsonConfig extends IEpsonConfig {} + +const epsonControlServices = [ + 'turn_on', + 'turn_off', + 'select_source', + 'volume_mute', + 'volume_up', + 'volume_down', + 'media_play', + 'media_pause', + 'media_next_track', + 'media_previous_track', + 'select_cmode', +]; + +export const epsonProfile: ISimpleLocalIntegrationProfile = { + domain: epsonDomain, + displayName: 'Epson', + manufacturer: 'Epson', + model: 'Projector', + defaultName: epsonDefaultName, + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'media_player', + ], + serviceDomains: [ + 'media_player', + 'epson', + ], + controlServices: epsonControlServices, + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'epson', + 'projector', + 'epson_projector', + 'emp', + 'serial', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/epson', + upstreamDomain: 'epson', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'epson-projector==0.6.0', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@pszafer', + ], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/epson', + haPlatforms: [ + 'media_player', + ], + connectionTypes: [ + 'http', + 'serial', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...epsonControlServices, + ], + platforms: [ + 'media_player', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual projector endpoint setup for host or serial path metadata', + 'snapshot, raw data, snapshotProvider, and injected native epson-projector client operation', + 'executor-gated media player and Epson color mode service dispatch', + ], + explicitUnsupported: [ + 'claiming projector command success without injected client.execute or commandExecutor', + 'direct epson-projector HTTP or serial protocol operation without an injected native client', + 'initial powered-on projector validation without a supplied native client', + ], + }, + }, +}; diff --git a/ts/integrations/epson/index.ts b/ts/integrations/epson/index.ts index c08bde3..0f4afa7 100644 --- a/ts/integrations/epson/index.ts +++ b/ts/integrations/epson/index.ts @@ -1,2 +1,6 @@ +export * from './epson.classes.client.js'; +export * from './epson.classes.configflow.js'; export * from './epson.classes.integration.js'; +export * from './epson.discovery.js'; +export * from './epson.mapper.js'; export * from './epson.types.js'; diff --git a/ts/integrations/eq3btsmart/.generated-by-smarthome-exchange b/ts/integrations/eq3btsmart/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/eq3btsmart/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/eq3btsmart/eq3btsmart.classes.client.ts b/ts/integrations/eq3btsmart/eq3btsmart.classes.client.ts new file mode 100644 index 0000000..57377d8 --- /dev/null +++ b/ts/integrations/eq3btsmart/eq3btsmart.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEq3btsmartConfig } from './eq3btsmart.types.js'; +import { eq3btsmartProfile } from './eq3btsmart.types.js'; + +export class Eq3btsmartClient extends SimpleLocalClient { + constructor(configArg: IEq3btsmartConfig) { + super(eq3btsmartProfile, configArg); + } +} diff --git a/ts/integrations/eq3btsmart/eq3btsmart.classes.configflow.ts b/ts/integrations/eq3btsmart/eq3btsmart.classes.configflow.ts new file mode 100644 index 0000000..d869969 --- /dev/null +++ b/ts/integrations/eq3btsmart/eq3btsmart.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEq3btsmartConfig } from './eq3btsmart.types.js'; +import { eq3btsmartProfile } from './eq3btsmart.types.js'; + +export class Eq3btsmartConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(eq3btsmartProfile); + } +} diff --git a/ts/integrations/eq3btsmart/eq3btsmart.classes.integration.ts b/ts/integrations/eq3btsmart/eq3btsmart.classes.integration.ts index d85c373..d5bb0eb 100644 --- a/ts/integrations/eq3btsmart/eq3btsmart.classes.integration.ts +++ b/ts/integrations/eq3btsmart/eq3btsmart.classes.integration.ts @@ -1,30 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { Eq3btsmartConfigFlow } from './eq3btsmart.classes.configflow.js'; +import { createEq3btsmartDiscoveryDescriptor } from './eq3btsmart.discovery.js'; +import type { IEq3btsmartConfig } from './eq3btsmart.types.js'; +import { eq3btsmartDomain, eq3btsmartProfile } from './eq3btsmart.types.js'; + +export class Eq3btsmartIntegration extends SimpleLocalIntegration { + public readonly domain = eq3btsmartDomain; + public readonly discoveryDescriptor = createEq3btsmartDiscoveryDescriptor(); + public readonly configFlow = new Eq3btsmartConfigFlow(); -export class HomeAssistantEq3btsmartIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "eq3btsmart", - displayName: "eQ-3 Bluetooth Smart Thermostats", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/eq3btsmart", - "upstreamDomain": "eq3btsmart", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "eq3btsmart==2.3.0" - ], - "dependencies": [ - "bluetooth", - "bluetooth_adapters" - ], - "afterDependencies": [], - "codeowners": [ - "@eulemitkeule", - "@dbuezas" - ] -}, - }); + super(eq3btsmartProfile); } } + +export class HomeAssistantEq3btsmartIntegration extends Eq3btsmartIntegration {} diff --git a/ts/integrations/eq3btsmart/eq3btsmart.discovery.ts b/ts/integrations/eq3btsmart/eq3btsmart.discovery.ts new file mode 100644 index 0000000..6ee33c9 --- /dev/null +++ b/ts/integrations/eq3btsmart/eq3btsmart.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { eq3btsmartProfile } from './eq3btsmart.types.js'; + +export const createEq3btsmartDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(eq3btsmartProfile); diff --git a/ts/integrations/eq3btsmart/eq3btsmart.mapper.ts b/ts/integrations/eq3btsmart/eq3btsmart.mapper.ts new file mode 100644 index 0000000..fe69546 --- /dev/null +++ b/ts/integrations/eq3btsmart/eq3btsmart.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEq3btsmartConfig } from './eq3btsmart.types.js'; +import { eq3btsmartProfile } from './eq3btsmart.types.js'; + +export class Eq3btsmartMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: eq3btsmartProfile }); + } + + public static toSnapshotFromRaw(configArg: IEq3btsmartConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: eq3btsmartProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(eq3btsmartProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(eq3btsmartProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/eq3btsmart/eq3btsmart.types.ts b/ts/integrations/eq3btsmart/eq3btsmart.types.ts index c998784..b6327b7 100644 --- a/ts/integrations/eq3btsmart/eq3btsmart.types.ts +++ b/ts/integrations/eq3btsmart/eq3btsmart.types.ts @@ -1,4 +1,139 @@ -export interface IHomeAssistantEq3btsmartConfig { - // TODO: replace with the TypeScript-native config for eq3btsmart. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const eq3btsmartDomain = 'eq3btsmart'; +export const eq3btsmartDefaultName = 'eQ-3 Bluetooth Smart Thermostat'; + +export type TEq3btsmartRawData = TSimpleLocalRawData; +export type TEq3btsmartCurrentTemperatureSelector = 'NOTHING' | 'UI' | 'DEVICE' | 'VALVE' | 'ENTITY'; +export type TEq3btsmartTargetTemperatureSelector = 'TARGET' | 'LAST_REPORTED'; +export interface IEq3btsmartSnapshot extends ISimpleLocalSnapshot {} +export interface IEq3btsmartConfig extends ISimpleLocalConfig { + mac?: string; + macAddress?: string; + address?: string; + currentTempSelector?: TEq3btsmartCurrentTemperatureSelector; + targetTempSelector?: TEq3btsmartTargetTemperatureSelector; + externalTempSensor?: string; + scanInterval?: number; } +export interface IHomeAssistantEq3btsmartConfig extends IEq3btsmartConfig {} + +export const eq3btsmartProfile: ISimpleLocalIntegrationProfile = { + domain: eq3btsmartDomain, + displayName: 'eQ-3 Bluetooth Smart Thermostats', + manufacturer: 'eQ-3 AG', + model: 'CC-RT-BLE-EQ', + defaultName: eq3btsmartDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'binary_sensor', + 'climate', + 'number', + 'sensor', + 'switch', + ], + serviceDomains: [ + 'climate', + 'number', + 'switch', + ], + controlServices: [ + 'set_temperature', + 'set_hvac_mode', + 'set_preset_mode', + 'turn_on', + 'turn_off', + 'toggle', + 'set_value', + ], + discoverySources: [ + 'manual', + 'bluetooth', + 'custom', + ], + discoveryKeywords: [ + 'eq3btsmart', + 'eQ-3', + 'CC-RT-BLE', + 'CC-RT-M-BLE', + 'CC-RT-BLE-EQ', + 'thermostat', + 'radiator', + 'valve', + 'bluetooth', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/eq3btsmart', + upstreamDomain: 'eq3btsmart', + documentation: 'https://www.home-assistant.io/integrations/eq3btsmart', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'eq3btsmart==2.3.0', + ], + dependencies: [ + 'bluetooth', + 'bluetooth_adapters', + ], + afterDependencies: [], + codeowners: [ + '@eulemitkeule', + '@dbuezas', + ], + configFlow: true, + bluetooth: [ + { connectable: true, local_name: 'CC-RT-BLE' }, + { connectable: true, local_name: 'CC-RT-M-BLE' }, + { connectable: true, local_name: 'CC-RT-BLE-EQ' }, + ], + loggers: [ + 'eq3btsmart', + ], + homeAssistantPlatforms: [ + 'binary_sensor', + 'climate', + 'number', + 'sensor', + 'switch', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'set_temperature', + 'set_hvac_mode', + 'set_preset_mode', + 'turn_on', + 'turn_off', + 'toggle', + 'set_value', + ], + platforms: [ + 'binary_sensor', + 'climate', + 'number', + 'sensor', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual Bluetooth thermostat identity setup by MAC address or injected snapshot data', + 'Bluetooth discovery candidate normalization for CC-RT-BLE family advertisements', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + 'thermostat, preset, number, and switch service dispatch only through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'opening eQ-3 Bluetooth GATT sessions without an injected native client', + 'claiming live thermostat command success without injected client.execute or commandExecutor', + 'Home Assistant entity option selectors beyond the generic local snapshot/config surface', + ], + }, + }, +}; diff --git a/ts/integrations/eq3btsmart/index.ts b/ts/integrations/eq3btsmart/index.ts index f2dcca4..ef66809 100644 --- a/ts/integrations/eq3btsmart/index.ts +++ b/ts/integrations/eq3btsmart/index.ts @@ -1,2 +1,6 @@ +export * from './eq3btsmart.classes.client.js'; +export * from './eq3btsmart.classes.configflow.js'; export * from './eq3btsmart.classes.integration.js'; +export * from './eq3btsmart.discovery.js'; +export * from './eq3btsmart.mapper.js'; export * from './eq3btsmart.types.js'; diff --git a/ts/integrations/escea/.generated-by-smarthome-exchange b/ts/integrations/escea/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/escea/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/escea/escea.classes.client.ts b/ts/integrations/escea/escea.classes.client.ts new file mode 100644 index 0000000..d0a4605 --- /dev/null +++ b/ts/integrations/escea/escea.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEsceaConfig } from './escea.types.js'; +import { esceaProfile } from './escea.types.js'; + +export class EsceaClient extends SimpleLocalClient { + constructor(configArg: IEsceaConfig) { + super(esceaProfile, configArg); + } +} diff --git a/ts/integrations/escea/escea.classes.configflow.ts b/ts/integrations/escea/escea.classes.configflow.ts new file mode 100644 index 0000000..4216c73 --- /dev/null +++ b/ts/integrations/escea/escea.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEsceaConfig } from './escea.types.js'; +import { esceaProfile } from './escea.types.js'; + +export class EsceaConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(esceaProfile); + } +} diff --git a/ts/integrations/escea/escea.classes.integration.ts b/ts/integrations/escea/escea.classes.integration.ts index 5579a8b..5d9023f 100644 --- a/ts/integrations/escea/escea.classes.integration.ts +++ b/ts/integrations/escea/escea.classes.integration.ts @@ -1,26 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EsceaConfigFlow } from './escea.classes.configflow.js'; +import { createEsceaDiscoveryDescriptor } from './escea.discovery.js'; +import type { IEsceaConfig } from './escea.types.js'; +import { esceaDomain, esceaProfile } from './escea.types.js'; + +export class EsceaIntegration extends SimpleLocalIntegration { + public readonly domain = esceaDomain; + public readonly discoveryDescriptor = createEsceaDiscoveryDescriptor(); + public readonly configFlow = new EsceaConfigFlow(); -export class HomeAssistantEsceaIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "escea", - displayName: "Escea", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/escea", - "upstreamDomain": "escea", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "pescea==1.0.12" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@lazdavila" - ] -}, - }); + super(esceaProfile); } } + +export class HomeAssistantEsceaIntegration extends EsceaIntegration {} diff --git a/ts/integrations/escea/escea.discovery.ts b/ts/integrations/escea/escea.discovery.ts new file mode 100644 index 0000000..e7edd8f --- /dev/null +++ b/ts/integrations/escea/escea.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { esceaProfile } from './escea.types.js'; + +export const createEsceaDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(esceaProfile); diff --git a/ts/integrations/escea/escea.mapper.ts b/ts/integrations/escea/escea.mapper.ts new file mode 100644 index 0000000..79ae8df --- /dev/null +++ b/ts/integrations/escea/escea.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEsceaConfig } from './escea.types.js'; +import { esceaProfile } from './escea.types.js'; + +export class EsceaMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: esceaProfile }); + } + + public static toSnapshotFromRaw(configArg: IEsceaConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: esceaProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(esceaProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(esceaProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/escea/escea.types.ts b/ts/integrations/escea/escea.types.ts index 88bc24c..0c06694 100644 --- a/ts/integrations/escea/escea.types.ts +++ b/ts/integrations/escea/escea.types.ts @@ -1,4 +1,101 @@ -export interface IHomeAssistantEsceaConfig { - // TODO: replace with the TypeScript-native config for escea. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const esceaDomain = 'escea'; +export const esceaDefaultName = 'Escea Fireplace'; + +export type TEsceaRawData = TSimpleLocalRawData; +export interface IEsceaSnapshot extends ISimpleLocalSnapshot {} +export interface IEsceaConfig extends ISimpleLocalConfig { + controllerId?: string; + deviceUid?: string; } +export interface IHomeAssistantEsceaConfig extends IEsceaConfig {} + +export const esceaProfile: ISimpleLocalIntegrationProfile = { + domain: esceaDomain, + displayName: 'Escea', + manufacturer: 'Escea', + model: 'Escea Fireplace', + defaultName: esceaDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'climate', + ], + serviceDomains: [ + 'climate', + ], + controlServices: [ + 'set_temperature', + 'set_hvac_mode', + 'set_fan_mode', + 'turn_on', + 'turn_off', + ], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'escea', + 'fireplace', + 'controller', + 'heater', + 'homekit', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/escea', + upstreamDomain: 'escea', + documentation: 'https://www.home-assistant.io/integrations/escea', + integrationType: 'device', + iotClass: 'local_push', + qualityScale: undefined, + requirements: [ + 'pescea==1.0.12', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@lazdavila', + ], + configFlow: true, + homekit: { + models: [ + 'Escea', + ], + }, + homeAssistantPlatforms: [ + 'climate', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'set_temperature', + 'set_hvac_mode', + 'set_fan_mode', + 'turn_on', + 'turn_off', + ], + platforms: [ + 'climate', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local controller setup from discovered Escea controller metadata', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + 'climate service dispatch only through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'starting the pescea discovery service directly from this TypeScript runtime', + 'claiming live fireplace command success without injected client.execute or commandExecutor', + 'controller reconnection event streaming without an injected native client or snapshotProvider', + ], + }, + }, +}; diff --git a/ts/integrations/escea/index.ts b/ts/integrations/escea/index.ts index ab29011..354151b 100644 --- a/ts/integrations/escea/index.ts +++ b/ts/integrations/escea/index.ts @@ -1,2 +1,6 @@ +export * from './escea.classes.client.js'; +export * from './escea.classes.configflow.js'; export * from './escea.classes.integration.js'; +export * from './escea.discovery.js'; +export * from './escea.mapper.js'; export * from './escea.types.js'; diff --git a/ts/integrations/eufy/.generated-by-smarthome-exchange b/ts/integrations/eufy/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/eufy/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/eufy/eufy.classes.client.ts b/ts/integrations/eufy/eufy.classes.client.ts new file mode 100644 index 0000000..1edc0bc --- /dev/null +++ b/ts/integrations/eufy/eufy.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEufyConfig } from './eufy.types.js'; +import { eufyProfile } from './eufy.types.js'; + +export class EufyClient extends SimpleLocalClient { + constructor(configArg: IEufyConfig) { + super(eufyProfile, configArg); + } +} diff --git a/ts/integrations/eufy/eufy.classes.configflow.ts b/ts/integrations/eufy/eufy.classes.configflow.ts new file mode 100644 index 0000000..91d35d6 --- /dev/null +++ b/ts/integrations/eufy/eufy.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEufyConfig } from './eufy.types.js'; +import { eufyProfile } from './eufy.types.js'; + +export class EufyConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(eufyProfile); + } +} diff --git a/ts/integrations/eufy/eufy.classes.integration.ts b/ts/integrations/eufy/eufy.classes.integration.ts index 2ba18c7..19b0fdc 100644 --- a/ts/integrations/eufy/eufy.classes.integration.ts +++ b/ts/integrations/eufy/eufy.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EufyConfigFlow } from './eufy.classes.configflow.js'; +import { createEufyDiscoveryDescriptor } from './eufy.discovery.js'; +import type { IEufyConfig } from './eufy.types.js'; +import { eufyDomain, eufyProfile } from './eufy.types.js'; + +export class EufyIntegration extends SimpleLocalIntegration { + public readonly domain = eufyDomain; + public readonly discoveryDescriptor = createEufyDiscoveryDescriptor(); + public readonly configFlow = new EufyConfigFlow(); -export class HomeAssistantEufyIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "eufy", - displayName: "EufyHome", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/eufy", - "upstreamDomain": "eufy", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "lakeside==0.13" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(eufyProfile); } } + +export class HomeAssistantEufyIntegration extends EufyIntegration {} diff --git a/ts/integrations/eufy/eufy.discovery.ts b/ts/integrations/eufy/eufy.discovery.ts new file mode 100644 index 0000000..a842db8 --- /dev/null +++ b/ts/integrations/eufy/eufy.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { eufyProfile } from './eufy.types.js'; + +export const createEufyDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(eufyProfile); diff --git a/ts/integrations/eufy/eufy.mapper.ts b/ts/integrations/eufy/eufy.mapper.ts new file mode 100644 index 0000000..5a2b438 --- /dev/null +++ b/ts/integrations/eufy/eufy.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEufyConfig } from './eufy.types.js'; +import { eufyProfile } from './eufy.types.js'; + +export class EufyMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: eufyProfile }); + } + + public static toSnapshotFromRaw(configArg: IEufyConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: eufyProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(eufyProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(eufyProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/eufy/eufy.types.ts b/ts/integrations/eufy/eufy.types.ts index 12e624d..5a5a619 100644 --- a/ts/integrations/eufy/eufy.types.ts +++ b/ts/integrations/eufy/eufy.types.ts @@ -1,4 +1,120 @@ -export interface IHomeAssistantEufyConfig { - // TODO: replace with the TypeScript-native config for eufy. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const eufyDomain = 'eufy'; +export const eufyDefaultName = 'EufyHome'; + +export type TEufyRawData = TSimpleLocalRawData; +export interface IEufyConfiguredDevice { + address: string; + accessToken: string; + type: string; + name?: string; } +export interface IEufySnapshot extends ISimpleLocalSnapshot {} +export interface IEufyConfig extends ISimpleLocalConfig { + address?: string; + accessToken?: string; + code?: string; + deviceType?: string; + type?: string; + devices?: IEufyConfiguredDevice[]; +} +export interface IHomeAssistantEufyConfig extends IEufyConfig {} + +export const eufyProfile: ISimpleLocalIntegrationProfile = { + domain: eufyDomain, + displayName: 'EufyHome', + manufacturer: 'Eufy', + model: 'EufyHome Device', + defaultName: eufyDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'light', + 'switch', + ], + serviceDomains: [ + 'light', + 'switch', + ], + controlServices: [ + 'turn_on', + 'turn_off', + 'toggle', + ], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'eufy', + 'eufyhome', + 'lakeside', + 'T1011', + 'T1012', + 'T1013', + 'T1201', + 'T1202', + 'T1203', + 'T1211', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/eufy', + upstreamDomain: 'eufy', + documentation: 'https://www.home-assistant.io/integrations/eufy', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [ + 'lakeside==0.13', + ], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: false, + loggers: [ + 'lakeside', + ], + supportedDeviceTypes: { + T1011: 'light', + T1012: 'light', + T1013: 'light', + T1201: 'switch', + T1202: 'switch', + T1203: 'switch', + T1211: 'switch', + }, + homeAssistantPlatforms: [ + 'light', + 'switch', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'turn_on', + 'turn_off', + 'toggle', + ], + platforms: [ + 'light', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local EufyHome device setup from address, access token, and device type metadata', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + 'light and switch service dispatch only through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'calling the lakeside device protocol directly without an injected native client', + 'cloud username/password device enumeration without a supplied snapshot or native client', + 'claiming live light or switch command success without injected client.execute or commandExecutor', + ], + }, + }, +}; diff --git a/ts/integrations/eufy/index.ts b/ts/integrations/eufy/index.ts index 43e7aac..3a249f9 100644 --- a/ts/integrations/eufy/index.ts +++ b/ts/integrations/eufy/index.ts @@ -1,2 +1,6 @@ +export * from './eufy.classes.client.js'; +export * from './eufy.classes.configflow.js'; export * from './eufy.classes.integration.js'; +export * from './eufy.discovery.js'; +export * from './eufy.mapper.js'; export * from './eufy.types.js'; diff --git a/ts/integrations/eufylife_ble/.generated-by-smarthome-exchange b/ts/integrations/eufylife_ble/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/eufylife_ble/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/eufylife_ble/eufylife_ble.classes.client.ts b/ts/integrations/eufylife_ble/eufylife_ble.classes.client.ts new file mode 100644 index 0000000..04ef0bf --- /dev/null +++ b/ts/integrations/eufylife_ble/eufylife_ble.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEufylifeBleConfig } from './eufylife_ble.types.js'; +import { eufylifeBleProfile } from './eufylife_ble.types.js'; + +export class EufylifeBleClient extends SimpleLocalClient { + constructor(configArg: IEufylifeBleConfig) { + super(eufylifeBleProfile, configArg); + } +} diff --git a/ts/integrations/eufylife_ble/eufylife_ble.classes.configflow.ts b/ts/integrations/eufylife_ble/eufylife_ble.classes.configflow.ts new file mode 100644 index 0000000..1a83cd2 --- /dev/null +++ b/ts/integrations/eufylife_ble/eufylife_ble.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEufylifeBleConfig } from './eufylife_ble.types.js'; +import { eufylifeBleProfile } from './eufylife_ble.types.js'; + +export class EufylifeBleConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(eufylifeBleProfile); + } +} diff --git a/ts/integrations/eufylife_ble/eufylife_ble.classes.integration.ts b/ts/integrations/eufylife_ble/eufylife_ble.classes.integration.ts index 3decc46..0e03bf4 100644 --- a/ts/integrations/eufylife_ble/eufylife_ble.classes.integration.ts +++ b/ts/integrations/eufylife_ble/eufylife_ble.classes.integration.ts @@ -1,28 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EufylifeBleConfigFlow } from './eufylife_ble.classes.configflow.js'; +import { createEufylifeBleDiscoveryDescriptor } from './eufylife_ble.discovery.js'; +import type { IEufylifeBleConfig } from './eufylife_ble.types.js'; +import { eufylifeBleDomain, eufylifeBleProfile } from './eufylife_ble.types.js'; + +export class EufylifeBleIntegration extends SimpleLocalIntegration { + public readonly domain = eufylifeBleDomain; + public readonly discoveryDescriptor = createEufylifeBleDiscoveryDescriptor(); + public readonly configFlow = new EufylifeBleConfigFlow(); -export class HomeAssistantEufylifeBleIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "eufylife_ble", - displayName: "EufyLife", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/eufylife_ble", - "upstreamDomain": "eufylife_ble", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "eufylife-ble-client==0.1.8" - ], - "dependencies": [ - "bluetooth_adapters" - ], - "afterDependencies": [], - "codeowners": [ - "@bdr99" - ] -}, - }); + super(eufylifeBleProfile); } } + +export class HomeAssistantEufylifeBleIntegration extends EufylifeBleIntegration {} diff --git a/ts/integrations/eufylife_ble/eufylife_ble.discovery.ts b/ts/integrations/eufylife_ble/eufylife_ble.discovery.ts new file mode 100644 index 0000000..7fb07dc --- /dev/null +++ b/ts/integrations/eufylife_ble/eufylife_ble.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { eufylifeBleProfile } from './eufylife_ble.types.js'; + +export const createEufylifeBleDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(eufylifeBleProfile); diff --git a/ts/integrations/eufylife_ble/eufylife_ble.mapper.ts b/ts/integrations/eufylife_ble/eufylife_ble.mapper.ts new file mode 100644 index 0000000..e0241c5 --- /dev/null +++ b/ts/integrations/eufylife_ble/eufylife_ble.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEufylifeBleConfig } from './eufylife_ble.types.js'; +import { eufylifeBleProfile } from './eufylife_ble.types.js'; + +export class EufylifeBleMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: eufylifeBleProfile }); + } + + public static toSnapshotFromRaw(configArg: IEufylifeBleConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: eufylifeBleProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(eufylifeBleProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(eufylifeBleProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/eufylife_ble/eufylife_ble.types.ts b/ts/integrations/eufylife_ble/eufylife_ble.types.ts index 723cba9..135ff3f 100644 --- a/ts/integrations/eufylife_ble/eufylife_ble.types.ts +++ b/ts/integrations/eufylife_ble/eufylife_ble.types.ts @@ -1,4 +1,91 @@ -export interface IHomeAssistantEufylifeBleConfig { - // TODO: replace with the TypeScript-native config for eufylife_ble. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const eufylifeBleDomain = 'eufylife_ble'; +export const eufylifeBleDefaultName = 'EufyLife'; + +export type TEufylifeBleRawData = TSimpleLocalRawData; +export interface IEufylifeBleSnapshot extends ISimpleLocalSnapshot {} +export interface IEufylifeBleConfig extends ISimpleLocalConfig { + address?: string; + model?: string; } +export interface IHomeAssistantEufylifeBleConfig extends IEufylifeBleConfig {} + +export const eufylifeBleProfile: ISimpleLocalIntegrationProfile = { + domain: 'eufylife_ble', + displayName: 'EufyLife', + manufacturer: 'Eufy', + model: 'EufyLife BLE device', + defaultName: eufylifeBleDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'bluetooth', + 'custom', + ], + discoveryKeywords: [ + 'eufylife', + 'eufy', + 'eufy t9140', + 'eufy t9146', + 'eufy t9147', + 'eufy t9148', + 'eufy t9149', + 'bluetooth scale', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/eufylife_ble', + upstreamDomain: 'eufylife_ble', + integrationType: 'device', + iotClass: 'local_push', + qualityScale: undefined, + requirements: [ + 'eufylife-ble-client==0.1.8', + ], + dependencies: [ + 'bluetooth_adapters', + ], + afterDependencies: [], + codeowners: [ + '@bdr99', + ], + configFlow: true, + bluetooth: [ + { local_name: 'eufy T9140' }, + { local_name: 'eufy T9146' }, + { local_name: 'eufy T9147' }, + { local_name: 'eufy T9148' }, + { local_name: 'eufy T9149' }, + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual Bluetooth snapshot setup from discovered device metadata, snapshots, rawData, snapshotProvider, and injected native clients', + 'generic local read runtime for EufyLife weight, real-time weight, and heart-rate sensor snapshots', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'active BLE connection management without an injected native client or snapshotProvider', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/eufylife_ble/index.ts b/ts/integrations/eufylife_ble/index.ts index 03600da..c39658e 100644 --- a/ts/integrations/eufylife_ble/index.ts +++ b/ts/integrations/eufylife_ble/index.ts @@ -1,2 +1,6 @@ +export * from './eufylife_ble.classes.client.js'; +export * from './eufylife_ble.classes.configflow.js'; export * from './eufylife_ble.classes.integration.js'; +export * from './eufylife_ble.discovery.js'; +export * from './eufylife_ble.mapper.js'; export * from './eufylife_ble.types.js'; diff --git a/ts/integrations/eurotronic_cometblue/.generated-by-smarthome-exchange b/ts/integrations/eurotronic_cometblue/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/eurotronic_cometblue/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.client.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.client.ts new file mode 100644 index 0000000..13518d6 --- /dev/null +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEurotronicCometblueConfig } from './eurotronic_cometblue.types.js'; +import { eurotronicCometblueProfile } from './eurotronic_cometblue.types.js'; + +export class EurotronicCometblueClient extends SimpleLocalClient { + constructor(configArg: IEurotronicCometblueConfig) { + super(eurotronicCometblueProfile, configArg); + } +} diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.configflow.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.configflow.ts new file mode 100644 index 0000000..2cbc39a --- /dev/null +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEurotronicCometblueConfig } from './eurotronic_cometblue.types.js'; +import { eurotronicCometblueProfile } from './eurotronic_cometblue.types.js'; + +export class EurotronicCometblueConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(eurotronicCometblueProfile); + } +} diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.integration.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.integration.ts index b7630df..0315c35 100644 --- a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.integration.ts +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.classes.integration.ts @@ -1,29 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EurotronicCometblueConfigFlow } from './eurotronic_cometblue.classes.configflow.js'; +import { createEurotronicCometblueDiscoveryDescriptor } from './eurotronic_cometblue.discovery.js'; +import type { IEurotronicCometblueConfig } from './eurotronic_cometblue.types.js'; +import { eurotronicCometblueDomain, eurotronicCometblueProfile } from './eurotronic_cometblue.types.js'; + +export class EurotronicCometblueIntegration extends SimpleLocalIntegration { + public readonly domain = eurotronicCometblueDomain; + public readonly discoveryDescriptor = createEurotronicCometblueDiscoveryDescriptor(); + public readonly configFlow = new EurotronicCometblueConfigFlow(); -export class HomeAssistantEurotronicCometblueIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "eurotronic_cometblue", - displayName: "Eurotronic Comet Blue", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/eurotronic_cometblue", - "upstreamDomain": "eurotronic_cometblue", - "integrationType": "device", - "iotClass": "local_polling", - "qualityScale": "bronze", - "requirements": [ - "eurotronic-cometblue-ha==1.4.0" - ], - "dependencies": [ - "bluetooth" - ], - "afterDependencies": [], - "codeowners": [ - "@rikroe" - ] -}, - }); + super(eurotronicCometblueProfile); } } + +export class HomeAssistantEurotronicCometblueIntegration extends EurotronicCometblueIntegration {} diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.discovery.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.discovery.ts new file mode 100644 index 0000000..afea900 --- /dev/null +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { eurotronicCometblueProfile } from './eurotronic_cometblue.types.js'; + +export const createEurotronicCometblueDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(eurotronicCometblueProfile); diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.mapper.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.mapper.ts new file mode 100644 index 0000000..289c0fc --- /dev/null +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEurotronicCometblueConfig } from './eurotronic_cometblue.types.js'; +import { eurotronicCometblueProfile } from './eurotronic_cometblue.types.js'; + +export class EurotronicCometblueMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: eurotronicCometblueProfile }); + } + + public static toSnapshotFromRaw(configArg: IEurotronicCometblueConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: eurotronicCometblueProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(eurotronicCometblueProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(eurotronicCometblueProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.types.ts b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.types.ts index e5dd0e6..fe050cc 100644 --- a/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.types.ts +++ b/ts/integrations/eurotronic_cometblue/eurotronic_cometblue.types.ts @@ -1,4 +1,108 @@ -export interface IHomeAssistantEurotronicCometblueConfig { - // TODO: replace with the TypeScript-native config for eurotronic_cometblue. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const eurotronicCometblueDomain = 'eurotronic_cometblue'; +export const eurotronicCometblueDefaultName = 'Eurotronic Comet Blue'; +export const eurotronicCometblueControlServices = [ + 'set_temperature', + 'set_hvac_mode', + 'set_preset_mode', + 'turn_on', + 'turn_off', + 'press', +]; + +export type TEurotronicCometblueRawData = TSimpleLocalRawData; +export interface IEurotronicCometblueSnapshot extends ISimpleLocalSnapshot {} +export interface IEurotronicCometblueConfig extends ISimpleLocalConfig { + address?: string; + pin?: string | number; } +export interface IHomeAssistantEurotronicCometblueConfig extends IEurotronicCometblueConfig {} + +export const eurotronicCometblueProfile: ISimpleLocalIntegrationProfile = { + domain: 'eurotronic_cometblue', + displayName: 'Eurotronic Comet Blue', + manufacturer: 'Eurotronic', + model: 'Comet Blue', + defaultName: eurotronicCometblueDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'climate', + 'sensor', + 'button', + ], + serviceDomains: [ + 'climate', + 'button', + ], + controlServices: eurotronicCometblueControlServices, + discoverySources: [ + 'manual', + 'bluetooth', + 'custom', + ], + discoveryKeywords: [ + 'eurotronic', + 'comet blue', + 'cometblue', + '47e9ee00-47e9-11e4-8939-164230d1df67', + 'bluetooth trv', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/eurotronic_cometblue', + upstreamDomain: 'eurotronic_cometblue', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'bronze', + qualityScaleRulesPath: 'homeassistant/components/eurotronic_cometblue/quality_scale.yaml', + requirements: [ + 'eurotronic-cometblue-ha==1.4.0', + ], + dependencies: [ + 'bluetooth', + ], + afterDependencies: [], + codeowners: [ + '@rikroe', + ], + configFlow: true, + bluetooth: [ + { + connectable: true, + service_uuid: '47e9ee00-47e9-11e4-8939-164230d1df67', + }, + ], + loggers: [ + 'eurotronic_cometblue_ha', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...eurotronicCometblueControlServices, + ], + platforms: [ + 'climate', + 'sensor', + 'button', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual Bluetooth snapshot setup from discovered device metadata, snapshots, rawData, snapshotProvider, and injected native clients', + 'generic local read runtime for thermostat, battery, and sync-time button snapshots', + 'executor-gated thermostat and sync-time button commands through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'validating the Bluetooth PIN or opening BLE sessions without an injected native client or snapshotProvider', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/eurotronic_cometblue/index.ts b/ts/integrations/eurotronic_cometblue/index.ts index 2ed0705..b3dc768 100644 --- a/ts/integrations/eurotronic_cometblue/index.ts +++ b/ts/integrations/eurotronic_cometblue/index.ts @@ -1,2 +1,6 @@ +export * from './eurotronic_cometblue.classes.client.js'; +export * from './eurotronic_cometblue.classes.configflow.js'; export * from './eurotronic_cometblue.classes.integration.js'; +export * from './eurotronic_cometblue.discovery.js'; +export * from './eurotronic_cometblue.mapper.js'; export * from './eurotronic_cometblue.types.js'; diff --git a/ts/integrations/everlights/.generated-by-smarthome-exchange b/ts/integrations/everlights/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/everlights/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/everlights/everlights.classes.client.ts b/ts/integrations/everlights/everlights.classes.client.ts new file mode 100644 index 0000000..f010e30 --- /dev/null +++ b/ts/integrations/everlights/everlights.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEverlightsConfig } from './everlights.types.js'; +import { everlightsProfile } from './everlights.types.js'; + +export class EverlightsClient extends SimpleLocalClient { + constructor(configArg: IEverlightsConfig) { + super(everlightsProfile, configArg); + } +} diff --git a/ts/integrations/everlights/everlights.classes.configflow.ts b/ts/integrations/everlights/everlights.classes.configflow.ts new file mode 100644 index 0000000..3f0c2bd --- /dev/null +++ b/ts/integrations/everlights/everlights.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEverlightsConfig } from './everlights.types.js'; +import { everlightsProfile } from './everlights.types.js'; + +export class EverlightsConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(everlightsProfile); + } +} diff --git a/ts/integrations/everlights/everlights.classes.integration.ts b/ts/integrations/everlights/everlights.classes.integration.ts index af7ccae..9053729 100644 --- a/ts/integrations/everlights/everlights.classes.integration.ts +++ b/ts/integrations/everlights/everlights.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EverlightsConfigFlow } from './everlights.classes.configflow.js'; +import { createEverlightsDiscoveryDescriptor } from './everlights.discovery.js'; +import type { IEverlightsConfig } from './everlights.types.js'; +import { everlightsDomain, everlightsProfile } from './everlights.types.js'; + +export class EverlightsIntegration extends SimpleLocalIntegration { + public readonly domain = everlightsDomain; + public readonly discoveryDescriptor = createEverlightsDiscoveryDescriptor(); + public readonly configFlow = new EverlightsConfigFlow(); -export class HomeAssistantEverlightsIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "everlights", - displayName: "EverLights", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/everlights", - "upstreamDomain": "everlights", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "pyeverlights==0.1.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(everlightsProfile); } } + +export class HomeAssistantEverlightsIntegration extends EverlightsIntegration {} diff --git a/ts/integrations/everlights/everlights.discovery.ts b/ts/integrations/everlights/everlights.discovery.ts new file mode 100644 index 0000000..3b3f4cb --- /dev/null +++ b/ts/integrations/everlights/everlights.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { everlightsProfile } from './everlights.types.js'; + +export const createEverlightsDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(everlightsProfile); diff --git a/ts/integrations/everlights/everlights.mapper.ts b/ts/integrations/everlights/everlights.mapper.ts new file mode 100644 index 0000000..b871e13 --- /dev/null +++ b/ts/integrations/everlights/everlights.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEverlightsConfig } from './everlights.types.js'; +import { everlightsProfile } from './everlights.types.js'; + +export class EverlightsMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: everlightsProfile }); + } + + public static toSnapshotFromRaw(configArg: IEverlightsConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: everlightsProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(everlightsProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(everlightsProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/everlights/everlights.types.ts b/ts/integrations/everlights/everlights.types.ts index 9ab87d3..5b2d492 100644 --- a/ts/integrations/everlights/everlights.types.ts +++ b/ts/integrations/everlights/everlights.types.ts @@ -1,4 +1,86 @@ -export interface IHomeAssistantEverlightsConfig { - // TODO: replace with the TypeScript-native config for everlights. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const everlightsDomain = 'everlights'; +export const everlightsDefaultName = 'EverLights'; +export const everlightsControlServices = [ + 'turn_on', + 'turn_off', +]; + +export type TEverlightsRawData = TSimpleLocalRawData; +export interface IEverlightsSnapshot extends ISimpleLocalSnapshot {} +export interface IEverlightsConfig extends ISimpleLocalConfig { + hosts?: string[]; } +export interface IHomeAssistantEverlightsConfig extends IEverlightsConfig {} + +export const everlightsProfile: ISimpleLocalIntegrationProfile = { + domain: 'everlights', + displayName: 'EverLights', + manufacturer: 'EverLights', + model: 'EverLights controller', + defaultName: everlightsDefaultName, + defaultPort: 80, + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'light', + ], + serviceDomains: [ + 'light', + ], + controlServices: everlightsControlServices, + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'everlights', + 'pyeverlights', + 'light', + 'zone', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/everlights', + upstreamDomain: 'everlights', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [ + 'pyeverlights==0.1.0', + ], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: false, + loggers: [ + 'pyeverlights', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...everlightsControlServices, + ], + platforms: [ + 'light', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local controller setup by host, snapshots, rawData, snapshotProvider, and injected native clients', + 'generic local read runtime for EverLights controller and zone snapshots', + 'executor-gated light turn_on and turn_off commands through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'calling the pyeverlights HTTP API directly without an injected native client or snapshotProvider', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/everlights/index.ts b/ts/integrations/everlights/index.ts index a85d49d..14cfa0a 100644 --- a/ts/integrations/everlights/index.ts +++ b/ts/integrations/everlights/index.ts @@ -1,2 +1,6 @@ +export * from './everlights.classes.client.js'; +export * from './everlights.classes.configflow.js'; export * from './everlights.classes.integration.js'; +export * from './everlights.discovery.js'; +export * from './everlights.mapper.js'; export * from './everlights.types.js'; diff --git a/ts/integrations/evil_genius_labs/.generated-by-smarthome-exchange b/ts/integrations/evil_genius_labs/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/evil_genius_labs/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.classes.client.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.client.ts new file mode 100644 index 0000000..99884f5 --- /dev/null +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IEvilGeniusLabsConfig } from './evil_genius_labs.types.js'; +import { evilGeniusLabsProfile } from './evil_genius_labs.types.js'; + +export class EvilGeniusLabsClient extends SimpleLocalClient { + constructor(configArg: IEvilGeniusLabsConfig) { + super(evilGeniusLabsProfile, configArg); + } +} diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.classes.configflow.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.configflow.ts new file mode 100644 index 0000000..a4eb89b --- /dev/null +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IEvilGeniusLabsConfig } from './evil_genius_labs.types.js'; +import { evilGeniusLabsProfile } from './evil_genius_labs.types.js'; + +export class EvilGeniusLabsConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(evilGeniusLabsProfile); + } +} diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.classes.integration.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.integration.ts index 47bdbc8..610cd75 100644 --- a/ts/integrations/evil_genius_labs/evil_genius_labs.classes.integration.ts +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { EvilGeniusLabsConfigFlow } from './evil_genius_labs.classes.configflow.js'; +import { createEvilGeniusLabsDiscoveryDescriptor } from './evil_genius_labs.discovery.js'; +import type { IEvilGeniusLabsConfig } from './evil_genius_labs.types.js'; +import { evilGeniusLabsDomain, evilGeniusLabsProfile } from './evil_genius_labs.types.js'; + +export class EvilGeniusLabsIntegration extends SimpleLocalIntegration { + public readonly domain = evilGeniusLabsDomain; + public readonly discoveryDescriptor = createEvilGeniusLabsDiscoveryDescriptor(); + public readonly configFlow = new EvilGeniusLabsConfigFlow(); -export class HomeAssistantEvilGeniusLabsIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "evil_genius_labs", - displayName: "Evil Genius Labs", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/evil_genius_labs", - "upstreamDomain": "evil_genius_labs", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "pyevilgenius==2.0.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(evilGeniusLabsProfile); } } + +export class HomeAssistantEvilGeniusLabsIntegration extends EvilGeniusLabsIntegration {} diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.discovery.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.discovery.ts new file mode 100644 index 0000000..9fb8fed --- /dev/null +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { evilGeniusLabsProfile } from './evil_genius_labs.types.js'; + +export const createEvilGeniusLabsDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(evilGeniusLabsProfile); diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.mapper.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.mapper.ts new file mode 100644 index 0000000..6ef4233 --- /dev/null +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IEvilGeniusLabsConfig } from './evil_genius_labs.types.js'; +import { evilGeniusLabsProfile } from './evil_genius_labs.types.js'; + +export class EvilGeniusLabsMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: evilGeniusLabsProfile }); + } + + public static toSnapshotFromRaw(configArg: IEvilGeniusLabsConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: evilGeniusLabsProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(evilGeniusLabsProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(evilGeniusLabsProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/evil_genius_labs/evil_genius_labs.types.ts b/ts/integrations/evil_genius_labs/evil_genius_labs.types.ts index 5d9f11e..6b73e89 100644 --- a/ts/integrations/evil_genius_labs/evil_genius_labs.types.ts +++ b/ts/integrations/evil_genius_labs/evil_genius_labs.types.ts @@ -1,4 +1,91 @@ -export interface IHomeAssistantEvilGeniusLabsConfig { - // TODO: replace with the TypeScript-native config for evil_genius_labs. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const evilGeniusLabsDomain = 'evil_genius_labs'; +export const evilGeniusLabsDefaultName = 'Evil Genius Labs'; + +export type TEvilGeniusLabsRawData = TSimpleLocalRawData; +export interface IEvilGeniusLabsSnapshot extends ISimpleLocalSnapshot {} +export interface IEvilGeniusLabsConfig extends ISimpleLocalConfig { + host?: string; } +export interface IHomeAssistantEvilGeniusLabsConfig extends IEvilGeniusLabsConfig {} + +export const evilGeniusLabsProfile: ISimpleLocalIntegrationProfile = { + domain: 'evil_genius_labs', + displayName: 'Evil Genius Labs', + manufacturer: 'Evil Genius Labs', + model: 'Evil Genius Labs light', + defaultName: evilGeniusLabsDefaultName, + defaultHttpPath: '/all', + defaultProtocol: 'http', + status: 'control-runtime', + platforms: [ + 'light', + ], + serviceDomains: [ + 'light', + ], + controlServices: [ + 'turn_on', + 'turn_off', + 'toggle', + 'set_level', + 'set_value', + ], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'evil genius labs', + 'evil_genius_labs', + 'fibonacci', + 'led', + 'light', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/evil_genius_labs', + upstreamDomain: 'evil_genius_labs', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'pyevilgenius==2.0.0', + ], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: true, + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'turn_on', + 'turn_off', + 'toggle', + 'set_level', + 'set_value', + ], + platforms: [ + 'light', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual local HTTP setup for Evil Genius Labs /all snapshots', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + 'delegated light control through injected client.execute or commandExecutor', + ], + explicitUnsupported: [ + 'claiming live light command success without injected client.execute or commandExecutor', + 'device info and product enrichment beyond /all without an injected snapshot or client', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/evil_genius_labs/index.ts b/ts/integrations/evil_genius_labs/index.ts index 5658d92..492364f 100644 --- a/ts/integrations/evil_genius_labs/index.ts +++ b/ts/integrations/evil_genius_labs/index.ts @@ -1,2 +1,6 @@ +export * from './evil_genius_labs.classes.client.js'; +export * from './evil_genius_labs.classes.configflow.js'; export * from './evil_genius_labs.classes.integration.js'; +export * from './evil_genius_labs.discovery.js'; +export * from './evil_genius_labs.mapper.js'; export * from './evil_genius_labs.types.js'; diff --git a/ts/integrations/fail2ban/.generated-by-smarthome-exchange b/ts/integrations/fail2ban/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fail2ban/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/fail2ban/fail2ban.classes.client.ts b/ts/integrations/fail2ban/fail2ban.classes.client.ts new file mode 100644 index 0000000..0a5cc72 --- /dev/null +++ b/ts/integrations/fail2ban/fail2ban.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFail2banConfig } from './fail2ban.types.js'; +import { fail2banProfile } from './fail2ban.types.js'; + +export class Fail2banClient extends SimpleLocalClient { + constructor(configArg: IFail2banConfig) { + super(fail2banProfile, configArg); + } +} diff --git a/ts/integrations/fail2ban/fail2ban.classes.configflow.ts b/ts/integrations/fail2ban/fail2ban.classes.configflow.ts new file mode 100644 index 0000000..2a2d0e7 --- /dev/null +++ b/ts/integrations/fail2ban/fail2ban.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFail2banConfig } from './fail2ban.types.js'; +import { fail2banProfile } from './fail2ban.types.js'; + +export class Fail2banConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fail2banProfile); + } +} diff --git a/ts/integrations/fail2ban/fail2ban.classes.integration.ts b/ts/integrations/fail2ban/fail2ban.classes.integration.ts index 2aa3f08..9d98524 100644 --- a/ts/integrations/fail2ban/fail2ban.classes.integration.ts +++ b/ts/integrations/fail2ban/fail2ban.classes.integration.ts @@ -1,22 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { Fail2banConfigFlow } from './fail2ban.classes.configflow.js'; +import { createFail2banDiscoveryDescriptor } from './fail2ban.discovery.js'; +import type { IFail2banConfig } from './fail2ban.types.js'; +import { fail2banDomain, fail2banProfile } from './fail2ban.types.js'; + +export class Fail2banIntegration extends SimpleLocalIntegration { + public readonly domain = fail2banDomain; + public readonly discoveryDescriptor = createFail2banDiscoveryDescriptor(); + public readonly configFlow = new Fail2banConfigFlow(); -export class HomeAssistantFail2banIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "fail2ban", - displayName: "Fail2Ban", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fail2ban", - "upstreamDomain": "fail2ban", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(fail2banProfile); } } + +export class HomeAssistantFail2banIntegration extends Fail2banIntegration {} diff --git a/ts/integrations/fail2ban/fail2ban.discovery.ts b/ts/integrations/fail2ban/fail2ban.discovery.ts new file mode 100644 index 0000000..fde2fbd --- /dev/null +++ b/ts/integrations/fail2ban/fail2ban.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fail2banProfile } from './fail2ban.types.js'; + +export const createFail2banDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fail2banProfile); diff --git a/ts/integrations/fail2ban/fail2ban.mapper.ts b/ts/integrations/fail2ban/fail2ban.mapper.ts new file mode 100644 index 0000000..76cee68 --- /dev/null +++ b/ts/integrations/fail2ban/fail2ban.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFail2banConfig } from './fail2ban.types.js'; +import { fail2banProfile } from './fail2ban.types.js'; + +export class Fail2banMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: fail2banProfile }); + } + + public static toSnapshotFromRaw(configArg: IFail2banConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: fail2banProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fail2banProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fail2banProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/fail2ban/fail2ban.types.ts b/ts/integrations/fail2ban/fail2ban.types.ts index ddc04fb..1e5616f 100644 --- a/ts/integrations/fail2ban/fail2ban.types.ts +++ b/ts/integrations/fail2ban/fail2ban.types.ts @@ -1,4 +1,72 @@ -export interface IHomeAssistantFail2banConfig { - // TODO: replace with the TypeScript-native config for fail2ban. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fail2banDomain = 'fail2ban'; +export const fail2banDefaultName = 'fail2ban'; + +export type TFail2banRawData = TSimpleLocalRawData; +export interface IFail2banSnapshot extends ISimpleLocalSnapshot {} +export interface IFail2banConfig extends ISimpleLocalConfig { + filePath?: string; + jails?: string[]; } +export interface IHomeAssistantFail2banConfig extends IFail2banConfig {} + +export const fail2banProfile: ISimpleLocalIntegrationProfile = { + domain: 'fail2ban', + displayName: 'Fail2Ban', + model: 'Fail2Ban log parser', + defaultName: fail2banDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'fail2ban', + 'ban', + 'jail', + 'firewall', + 'log', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fail2ban', + upstreamDomain: 'fail2ban', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: false, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local Fail2Ban jail snapshot setup', + 'snapshot, raw data, rawText, snapshotProvider, and injected native client operation', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'reading arbitrary host log files unless provided through rawText, snapshotProvider, or an injected client', + 'mutating Fail2Ban jail state or firewall rules', + ], + }, + }, +}; diff --git a/ts/integrations/fail2ban/index.ts b/ts/integrations/fail2ban/index.ts index 1b6a951..308ec6a 100644 --- a/ts/integrations/fail2ban/index.ts +++ b/ts/integrations/fail2ban/index.ts @@ -1,2 +1,6 @@ +export * from './fail2ban.classes.client.js'; +export * from './fail2ban.classes.configflow.js'; export * from './fail2ban.classes.integration.js'; +export * from './fail2ban.discovery.js'; +export * from './fail2ban.mapper.js'; export * from './fail2ban.types.js'; diff --git a/ts/integrations/familyhub/.generated-by-smarthome-exchange b/ts/integrations/familyhub/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/familyhub/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/familyhub/familyhub.classes.client.ts b/ts/integrations/familyhub/familyhub.classes.client.ts new file mode 100644 index 0000000..9a1b341 --- /dev/null +++ b/ts/integrations/familyhub/familyhub.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFamilyhubConfig } from './familyhub.types.js'; +import { familyhubProfile } from './familyhub.types.js'; + +export class FamilyhubClient extends SimpleLocalClient { + constructor(configArg: IFamilyhubConfig) { + super(familyhubProfile, configArg); + } +} diff --git a/ts/integrations/familyhub/familyhub.classes.configflow.ts b/ts/integrations/familyhub/familyhub.classes.configflow.ts new file mode 100644 index 0000000..4985b8b --- /dev/null +++ b/ts/integrations/familyhub/familyhub.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFamilyhubConfig } from './familyhub.types.js'; +import { familyhubProfile } from './familyhub.types.js'; + +export class FamilyhubConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(familyhubProfile); + } +} diff --git a/ts/integrations/familyhub/familyhub.classes.integration.ts b/ts/integrations/familyhub/familyhub.classes.integration.ts index 287eb88..e9619da 100644 --- a/ts/integrations/familyhub/familyhub.classes.integration.ts +++ b/ts/integrations/familyhub/familyhub.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FamilyhubConfigFlow } from './familyhub.classes.configflow.js'; +import { createFamilyhubDiscoveryDescriptor } from './familyhub.discovery.js'; +import type { IFamilyhubConfig } from './familyhub.types.js'; +import { familyhubDomain, familyhubProfile } from './familyhub.types.js'; + +export class FamilyhubIntegration extends SimpleLocalIntegration { + public readonly domain = familyhubDomain; + public readonly discoveryDescriptor = createFamilyhubDiscoveryDescriptor(); + public readonly configFlow = new FamilyhubConfigFlow(); -export class HomeAssistantFamilyhubIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "familyhub", - displayName: "Samsung Family Hub", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/familyhub", - "upstreamDomain": "familyhub", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "python-family-hub-local==0.0.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(familyhubProfile); } } + +export class HomeAssistantFamilyhubIntegration extends FamilyhubIntegration {} diff --git a/ts/integrations/familyhub/familyhub.discovery.ts b/ts/integrations/familyhub/familyhub.discovery.ts new file mode 100644 index 0000000..e69238c --- /dev/null +++ b/ts/integrations/familyhub/familyhub.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { familyhubProfile } from './familyhub.types.js'; + +export const createFamilyhubDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(familyhubProfile); diff --git a/ts/integrations/familyhub/familyhub.mapper.ts b/ts/integrations/familyhub/familyhub.mapper.ts new file mode 100644 index 0000000..55d83e3 --- /dev/null +++ b/ts/integrations/familyhub/familyhub.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFamilyhubConfig } from './familyhub.types.js'; +import { familyhubProfile } from './familyhub.types.js'; + +export class FamilyhubMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: familyhubProfile }); + } + + public static toSnapshotFromRaw(configArg: IFamilyhubConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: familyhubProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(familyhubProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(familyhubProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/familyhub/familyhub.types.ts b/ts/integrations/familyhub/familyhub.types.ts index 84047d0..60f0d92 100644 --- a/ts/integrations/familyhub/familyhub.types.ts +++ b/ts/integrations/familyhub/familyhub.types.ts @@ -1,4 +1,86 @@ -export interface IHomeAssistantFamilyhubConfig { - // TODO: replace with the TypeScript-native config for familyhub. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const familyhubDomain = 'familyhub'; +export const familyhubDefaultName = 'FamilyHub Camera'; + +export type TFamilyhubRawData = TSimpleLocalRawData; +export interface IFamilyhubSnapshot extends ISimpleLocalSnapshot {} +export interface IFamilyhubConfig extends ISimpleLocalConfig { + ipAddress?: string; } +export interface IHomeAssistantFamilyhubConfig extends IFamilyhubConfig {} + +export const familyhubProfile: ISimpleLocalIntegrationProfile = { + domain: 'familyhub', + displayName: 'Samsung Family Hub', + manufacturer: 'Samsung', + model: 'Family Hub refrigerator', + defaultName: familyhubDefaultName, + defaultPort: 17654, + defaultHttpPath: '/.krate/owner/share/scloud/glazeCameraInfo.txt', + defaultProtocol: 'http', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [ + 'camera', + ], + controlServices: [], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'familyhub', + 'family hub', + 'samsung', + 'fridge', + 'refrigerator', + 'glazeCameraInfo', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/familyhub', + upstreamDomain: 'familyhub', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [ + 'python-family-hub-local==0.0.2', + ], + dependencies: [], + afterDependencies: [], + codeowners: [], + configFlow: false, + loggers: [ + 'pyfamilyhublocal', + ], + homeAssistantPlatforms: [ + 'camera', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local HTTP camera-info setup on port 17654', + 'snapshot, raw data, snapshotProvider, and injected native client operation', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'composing or writing camera images without a provided snapshot or client representation', + 'cloud account flows and remote API polling', + ], + }, + }, +}; diff --git a/ts/integrations/familyhub/index.ts b/ts/integrations/familyhub/index.ts index 1931c95..82d7511 100644 --- a/ts/integrations/familyhub/index.ts +++ b/ts/integrations/familyhub/index.ts @@ -1,2 +1,6 @@ +export * from './familyhub.classes.client.js'; +export * from './familyhub.classes.configflow.js'; export * from './familyhub.classes.integration.js'; +export * from './familyhub.discovery.js'; +export * from './familyhub.mapper.js'; export * from './familyhub.types.js'; diff --git a/ts/integrations/fibaro/.generated-by-smarthome-exchange b/ts/integrations/fibaro/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fibaro/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/fibaro/fibaro.classes.client.ts b/ts/integrations/fibaro/fibaro.classes.client.ts new file mode 100644 index 0000000..b35b582 --- /dev/null +++ b/ts/integrations/fibaro/fibaro.classes.client.ts @@ -0,0 +1,23 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import { FibaroMapper } from './fibaro.mapper.js'; +import type { IFibaroConfig, IFibaroSnapshot } from './fibaro.types.js'; +import { fibaroProfile } from './fibaro.types.js'; + +export class FibaroClient extends SimpleLocalClient { + constructor(private readonly configArg: IFibaroConfig) { + super(fibaroProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + if (this.configArg.rawData !== undefined || this.configArg.rawText !== undefined || this.configArg.entities?.length) { + return FibaroMapper.toSnapshot({ config: this.configArg, rawData: this.configArg.rawData ?? this.configArg.rawText, online: this.configArg.online ?? true, source: 'manual' }); + } + + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return FibaroMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return FibaroMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } +} diff --git a/ts/integrations/fibaro/fibaro.classes.configflow.ts b/ts/integrations/fibaro/fibaro.classes.configflow.ts new file mode 100644 index 0000000..206797c --- /dev/null +++ b/ts/integrations/fibaro/fibaro.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFibaroConfig } from './fibaro.types.js'; +import { fibaroProfile } from './fibaro.types.js'; + +export class FibaroConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fibaroProfile); + } +} diff --git a/ts/integrations/fibaro/fibaro.classes.integration.ts b/ts/integrations/fibaro/fibaro.classes.integration.ts index 106b7c1..6e87c9b 100644 --- a/ts/integrations/fibaro/fibaro.classes.integration.ts +++ b/ts/integrations/fibaro/fibaro.classes.integration.ts @@ -1,26 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { FibaroClient } from './fibaro.classes.client.js'; +import { FibaroConfigFlow } from './fibaro.classes.configflow.js'; +import { createFibaroDiscoveryDescriptor } from './fibaro.discovery.js'; +import type { IFibaroConfig } from './fibaro.types.js'; +import { fibaroDomain, fibaroProfile } from './fibaro.types.js'; + +export class FibaroIntegration extends SimpleLocalIntegration { + public readonly domain = fibaroDomain; + public readonly discoveryDescriptor = createFibaroDiscoveryDescriptor(); + public readonly configFlow = new FibaroConfigFlow(); -export class HomeAssistantFibaroIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "fibaro", - displayName: "Fibaro", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fibaro", - "upstreamDomain": "fibaro", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "pyfibaro==0.8.3" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@rappenze" - ] -}, - }); + super(fibaroProfile); + } + + public async setup(configArg: IFibaroConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(fibaroProfile, new FibaroClient(configArg)); } } + +export class HomeAssistantFibaroIntegration extends FibaroIntegration {} diff --git a/ts/integrations/fibaro/fibaro.discovery.ts b/ts/integrations/fibaro/fibaro.discovery.ts new file mode 100644 index 0000000..bf29971 --- /dev/null +++ b/ts/integrations/fibaro/fibaro.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fibaroProfile } from './fibaro.types.js'; + +export const createFibaroDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fibaroProfile); diff --git a/ts/integrations/fibaro/fibaro.mapper.ts b/ts/integrations/fibaro/fibaro.mapper.ts new file mode 100644 index 0000000..ea34a24 --- /dev/null +++ b/ts/integrations/fibaro/fibaro.mapper.ts @@ -0,0 +1,305 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFibaroConfig } from './fibaro.types.js'; +import { fibaroDefaultName, fibaroProfile } from './fibaro.types.js'; + +const fibaroTypePlatformMap: Record = { + 'com.fibaro.multilevelSensor': 'sensor', + 'com.fibaro.binarySwitch': 'switch', + 'com.fibaro.multilevelSwitch': 'switch', + 'com.fibaro.FGD212': 'light', + 'com.fibaro.FGR': 'cover', + 'com.fibaro.doorSensor': 'binary_sensor', + 'com.fibaro.doorWindowSensor': 'binary_sensor', + 'com.fibaro.FGMS001': 'binary_sensor', + 'com.fibaro.heatDetector': 'binary_sensor', + 'com.fibaro.lifeDangerSensor': 'binary_sensor', + 'com.fibaro.smokeSensor': 'binary_sensor', + 'com.fibaro.remoteSwitch': 'switch', + 'com.fibaro.sensor': 'sensor', + 'com.fibaro.colorController': 'light', + 'com.fibaro.securitySensor': 'binary_sensor', + 'com.fibaro.hvac': 'climate', + 'com.fibaro.hvacSystem': 'climate', + 'com.fibaro.setpoint': 'climate', + 'com.fibaro.FGT001': 'climate', + 'com.fibaro.thermostatDanfoss': 'climate', + 'com.fibaro.doorLock': 'binary_sensor', + 'com.fibaro.binarySensor': 'binary_sensor', + 'com.fibaro.accelerometer': 'binary_sensor', +}; + +const sensorDeviceClassMap: Record = { + 'com.fibaro.temperatureSensor': 'temperature', + 'com.fibaro.humiditySensor': 'humidity', + 'com.fibaro.lightSensor': 'illuminance', + 'com.fibaro.energyMeter': 'energy', + CO2: 'co2', +}; + +const binarySensorDeviceClassMap: Record = { + 'com.fibaro.floodSensor': 'moisture', + 'com.fibaro.motionSensor': 'motion', + 'com.fibaro.doorSensor': 'door', + 'com.fibaro.doorWindowSensor': 'door', + 'com.fibaro.windowSensor': 'window', + 'com.fibaro.smokeSensor': 'smoke', + 'com.fibaro.FGMS001': 'motion', + 'com.fibaro.heatDetector': 'heat', + 'com.fibaro.accelerometer': 'moving', + 'com.fibaro.doorLock': 'lock', +}; + +export class FibaroMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: fibaroProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IFibaroConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fibaroProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fibaroProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IFibaroConfig, rawDataArg: unknown): unknown { + if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) { + return rawDataArg; + } + + const info = recordValue(rawDataArg.info) || recordValue(rawDataArg.hub) || recordValue(rawDataArg.controller) || {}; + const devices = recordsValue(rawDataArg.devices ?? rawDataArg.fibaro_devices); + const scenes = recordsValue(rawDataArg.scenes); + if (!devices.length && !scenes.length && !Object.keys(info).length) { + return rawDataArg; + } + + const host = configArg.host || hostFromUrl(configArg.url) || stringValue(rawDataArg.host) || stringValue(info.host); + const serialNumber = stringValue(info.serial_number) || stringValue(info.serialNumber) || configArg.uniqueId || host || fibaroProfile.domain; + const name = configArg.name || stringValue(info.hc_name) || stringValue(info.name) || fibaroDefaultName; + const entities = [ + ...devices.flatMap((deviceArg) => this.entitiesFromDevice(serialNumber, name, deviceArg)), + ...scenes.map((sceneArg) => this.entityFromScene(serialNumber, sceneArg)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg)), + ]; + + return { + device: { + id: serialNumber, + name, + manufacturer: stringValue(info.manufacturer_name) || fibaroProfile.manufacturer, + model: stringValue(info.model_name) || fibaroProfile.model, + serialNumber, + host, + protocol: fibaroProfile.defaultProtocol, + configurationUrl: host ? `http://${host}` : undefined, + attributes: { + currentVersion: stringValue(info.current_version) || stringValue(info.currentVersion), + macAddress: stringValue(info.mac_address) || stringValue(info.macAddress), + importPlugins: configArg.importPlugins ?? configArg.import_plugins, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } + + private static entitiesFromDevice(hubSerialArg: string, hubNameArg: string, deviceArg: Record): ISimpleLocalEntitySnapshot[] { + const id = idValue(deviceArg.fibaro_id) || idValue(deviceArg.id); + if (!id) { + return []; + } + + const properties = recordValue(deviceArg.properties) || {}; + const type = stringValue(deviceArg.type); + const baseType = stringValue(deviceArg.base_type) || stringValue(deviceArg.baseType); + const platform = platformFromDevice(type, baseType, properties, arrayValue(deviceArg.actions)); + const roomName = stringValue(deviceArg.room_name) || stringValue(deviceArg.roomName); + const name = stringValue(deviceArg.friendly_name) || [roomName, stringValue(deviceArg.name)].filter(Boolean).join(' ') || `${hubNameArg} ${id}`; + const value = normalizedState(platform, rawDeviceValue(deviceArg, properties)); + const available = !booleanValue(deviceArg.dead ?? properties.dead, false); + const entities: ISimpleLocalEntitySnapshot[] = [{ + id: `device_${SimpleLocalMapper.slug(id)}`, + uniqueId: `${fibaroProfile.domain}_${SimpleLocalMapper.slug(hubSerialArg)}_${SimpleLocalMapper.slug(id)}`, + name, + platform, + state: value, + available, + writable: false, + unit: unitValue(deviceArg.unit ?? properties.unit), + deviceClass: platform === 'sensor' ? sensorDeviceClassMap[type || ''] || sensorDeviceClassMap[baseType || ''] : platform === 'binary_sensor' ? binarySensorDeviceClassMap[type || ''] || binarySensorDeviceClassMap[baseType || ''] : undefined, + stateClass: platform === 'sensor' && typeof value === 'number' ? 'measurement' : undefined, + attributes: { + fibaroId: id, + type, + baseType, + roomName, + visible: deviceArg.visible, + parentFibaroId: deviceArg.parent_fibaro_id ?? deviceArg.parentFibaroId, + }, + }]; + + for (const key of ['power', 'energy']) { + const propertyValue = properties[key]; + if (propertyValue === undefined) { + continue; + } + entities.push({ + id: `device_${SimpleLocalMapper.slug(id)}_${key}`, + uniqueId: `${fibaroProfile.domain}_${SimpleLocalMapper.slug(hubSerialArg)}_${SimpleLocalMapper.slug(id)}_${key}`, + name: `${name} ${titleCase(key)}`, + platform: 'sensor', + state: numericOrString(propertyValue), + available, + writable: false, + unit: key === 'power' ? 'W' : 'kWh', + deviceClass: key, + stateClass: key === 'energy' ? 'total_increasing' : 'measurement', + attributes: { + fibaroId: id, + sourceProperty: key, + }, + }); + } + + return entities; + } + + private static entityFromScene(hubSerialArg: string, sceneArg: Record): ISimpleLocalEntitySnapshot | undefined { + const id = idValue(sceneArg.fibaro_id) || idValue(sceneArg.id); + if (!id) { + return undefined; + } + + return { + id: `scene_${SimpleLocalMapper.slug(id)}`, + uniqueId: `${fibaroProfile.domain}_${SimpleLocalMapper.slug(hubSerialArg)}_scene_${SimpleLocalMapper.slug(id)}`, + name: stringValue(sceneArg.name) || `Scene ${id}`, + platform: 'button', + state: 'available', + available: booleanValue(sceneArg.visible, true), + writable: false, + attributes: { + fibaroId: id, + serviceDomain: 'scene', + roomId: sceneArg.room_id ?? sceneArg.roomId, + }, + }; + } +} + +const platformFromDevice = (typeArg: string | undefined, baseTypeArg: string | undefined, propertiesArg: Record, actionsArg: unknown[]): TEntityPlatform => { + const mapped = (typeArg && fibaroTypePlatformMap[typeArg]) || (baseTypeArg && fibaroTypePlatformMap[baseTypeArg]); + if (mapped === 'switch' && propertiesArg.isLight === true) { + return 'light'; + } + if (mapped) { + return mapped; + } + const haystack = [typeArg, baseTypeArg].filter(Boolean).join(' ').toLowerCase(); + if (haystack.includes('temperature') || haystack.includes('humidity') || haystack.includes('lightSensor'.toLowerCase())) { + return 'sensor'; + } + if (haystack.includes('motion') || haystack.includes('door') || haystack.includes('window') || haystack.includes('smoke') || haystack.includes('flood') || haystack.includes('binary')) { + return 'binary_sensor'; + } + if (actionsArg.includes('setBrightness') || actionsArg.includes('setColor')) { + return 'light'; + } + if (actionsArg.includes('open')) { + return 'cover'; + } + if (actionsArg.includes('turnOn')) { + return 'switch'; + } + if (actionsArg.includes('secure')) { + return 'binary_sensor'; + } + return booleanState(propertiesArg.value) !== undefined ? 'binary_sensor' : 'sensor'; +}; + +const rawDeviceValue = (deviceArg: Record, propertiesArg: Record): unknown => propertiesArg.value ?? valueContainer(deviceArg.value) ?? deviceArg.state ?? propertiesArg.state ?? propertiesArg.power ?? propertiesArg.energy ?? null; + +const normalizedState = (platformArg: TEntityPlatform, valueArg: unknown): unknown => { + if (platformArg === 'binary_sensor' || platformArg === 'switch' || platformArg === 'light') { + return booleanState(valueArg) ?? Boolean(valueArg); + } + return numericOrString(valueArg); +}; + +const booleanState = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + const value = stringValue(valueArg)?.toLowerCase(); + if (!value) { + return undefined; + } + if (['1', 'true', 'on', 'open', 'opened', 'locked', 'secure', 'yes'].includes(value)) { + return true; + } + if (['0', 'false', 'off', 'closed', 'unlocked', 'unsecure', 'no'].includes(value)) { + return false; + } + return undefined; +}; + +const booleanValue = (valueArg: unknown, defaultArg: boolean): boolean => booleanState(valueArg) ?? defaultArg; + +const numericOrString = (valueArg: unknown): unknown => { + if (typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return valueArg; +}; + +const valueContainer = (valueArg: unknown): unknown => isRecord(valueArg) ? valueArg.value ?? valueArg.state : valueArg; + +const unitValue = (valueArg: unknown): string | undefined => stringValue(valueArg); + +const hostFromUrl = (valueArg: unknown): string | undefined => { + const value = stringValue(valueArg); + if (!value) { + return undefined; + } + try { + return new URL(value).hostname; + } catch { + return undefined; + } +}; + +const idValue = (valueArg: unknown): string | undefined => typeof valueArg === 'number' || typeof valueArg === 'string' ? String(valueArg) : undefined; + +const recordsValue = (valueArg: unknown): Array> => Array.isArray(valueArg) ? valueArg.filter(isRecord) : []; + +const arrayValue = (valueArg: unknown): unknown[] => Array.isArray(valueArg) ? valueArg : []; + +const recordValue = (valueArg: unknown): Record | undefined => isRecord(valueArg) ? valueArg : undefined; + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const titleCase = (valueArg: string): string => valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase()); diff --git a/ts/integrations/fibaro/fibaro.types.ts b/ts/integrations/fibaro/fibaro.types.ts index 285d682..0bf239b 100644 --- a/ts/integrations/fibaro/fibaro.types.ts +++ b/ts/integrations/fibaro/fibaro.types.ts @@ -1,4 +1,97 @@ -export interface IHomeAssistantFibaroConfig { - // TODO: replace with the TypeScript-native config for fibaro. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fibaroDomain = 'fibaro'; +export const fibaroDefaultName = 'Fibaro Home Center'; + +export type TFibaroRawData = TSimpleLocalRawData; +export interface IFibaroSnapshot extends ISimpleLocalSnapshot {} +export interface IFibaroConfig extends ISimpleLocalConfig { + url?: string; + importPlugins?: boolean; + import_plugins?: boolean; } +export interface IHomeAssistantFibaroConfig extends IFibaroConfig {} + +export const fibaroProfile: ISimpleLocalIntegrationProfile = { + domain: 'fibaro', + displayName: 'Fibaro', + manufacturer: 'Fibaro', + model: 'Home Center', + defaultName: fibaroDefaultName, + defaultHttpPath: '/api/', + defaultProtocol: 'http', + status: 'read-only-runtime', + platforms: [ + 'binary_sensor', + 'button', + 'climate', + 'cover', + 'light', + 'sensor', + 'switch', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'fibaro', + 'home center', + 'home center lite', + 'hc2', + 'hc3', + 'z-wave', + 'zwave', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fibaro', + upstreamDomain: 'fibaro', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: undefined, + requirements: [ + 'pyfibaro==0.8.3', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@rappenze', + ], + configFlow: true, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'binary_sensor', + 'climate', + 'cover', + 'event', + 'light', + 'lock', + 'scene', + 'sensor', + 'switch', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local Fibaro URL/API setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'Fibaro hub info, devices, power/energy attributes, and scenes snapshot mapping', + ], + explicitUnsupported: [ + 'opening a live Fibaro push connection without an injected client or snapshotProvider', + 'claiming live device, lock, cover, climate, light, switch, or scene command success without injected client.execute or commandExecutor', + 'Fibaro plugin import side effects without a real Fibaro client', + ], + }, + }, +}; diff --git a/ts/integrations/fibaro/index.ts b/ts/integrations/fibaro/index.ts index c29226d..ddad2ef 100644 --- a/ts/integrations/fibaro/index.ts +++ b/ts/integrations/fibaro/index.ts @@ -1,2 +1,6 @@ +export * from './fibaro.classes.client.js'; +export * from './fibaro.classes.configflow.js'; export * from './fibaro.classes.integration.js'; +export * from './fibaro.discovery.js'; +export * from './fibaro.mapper.js'; export * from './fibaro.types.js'; diff --git a/ts/integrations/file/.generated-by-smarthome-exchange b/ts/integrations/file/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/file/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/file/file.classes.client.ts b/ts/integrations/file/file.classes.client.ts new file mode 100644 index 0000000..e23a565 --- /dev/null +++ b/ts/integrations/file/file.classes.client.ts @@ -0,0 +1,56 @@ +import * as plugins from '../../plugins.js'; +import { SimpleLocalClient } from '../../core/index.js'; +import { FileMapper } from './file.mapper.js'; +import type { IFileConfig, IFileSnapshot } from './file.types.js'; +import { fileProfile } from './file.types.js'; + +export class FileClient extends SimpleLocalClient { + constructor(private readonly configArg: IFileConfig) { + super(fileProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + if (this.shouldReadLocalFile()) { + try { + return FileMapper.toSnapshot({ config: this.configArg, rawData: await this.readLocalFile(), online: true, source: 'manual' }); + } catch (errorArg) { + return FileMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: errorMessage(errorArg) }); + } + } + + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return FileMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return FileMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + private shouldReadLocalFile(): boolean { + return Boolean(this.filePath() && !this.configArg.snapshot && this.configArg.rawData === undefined && this.configArg.rawText === undefined && !this.configArg.entities?.length && !this.configArg.client && !this.configArg.snapshotProvider && !this.configArg.host); + } + + private async readLocalFile(): Promise> { + const filePath = this.filePath(); + if (!filePath) { + throw new Error('File snapshots require config.filePath or config.file_path.'); + } + + const content = await plugins.fs.readFile(filePath, 'utf8'); + const latestEntry = content.trimEnd().split(/\r?\n/).pop()?.trim() ?? ''; + return { + filePath, + latestEntry, + contentBytes: Buffer.byteLength(content), + }; + } + + private filePath(): string | undefined { + const metadata = this.configArg.metadata; + return stringValue(this.configArg.filePath) || stringValue(this.configArg.file_path) || stringValue(metadata?.filePath) || stringValue(metadata?.file_path); + } +} + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg); diff --git a/ts/integrations/file/file.classes.configflow.ts b/ts/integrations/file/file.classes.configflow.ts new file mode 100644 index 0000000..106b562 --- /dev/null +++ b/ts/integrations/file/file.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFileConfig } from './file.types.js'; +import { fileProfile } from './file.types.js'; + +export class FileConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fileProfile); + } +} diff --git a/ts/integrations/file/file.classes.integration.ts b/ts/integrations/file/file.classes.integration.ts index 6a86bc1..20fc02c 100644 --- a/ts/integrations/file/file.classes.integration.ts +++ b/ts/integrations/file/file.classes.integration.ts @@ -1,25 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { FileClient } from './file.classes.client.js'; +import { FileConfigFlow } from './file.classes.configflow.js'; +import { createFileDiscoveryDescriptor } from './file.discovery.js'; +import type { IFileConfig } from './file.types.js'; +import { fileDomain, fileProfile } from './file.types.js'; + +export class FileIntegration extends SimpleLocalIntegration { + public readonly domain = fileDomain; + public readonly discoveryDescriptor = createFileDiscoveryDescriptor(); + public readonly configFlow = new FileConfigFlow(); -export class HomeAssistantFileIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "file", - displayName: "File", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/file", - "upstreamDomain": "file", - "iotClass": "local_polling", - "requirements": [ - "file-read-backwards==2.0.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@fabaff" - ] -}, - }); + super(fileProfile); + } + + public async setup(configArg: IFileConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(fileProfile, new FileClient(configArg)); } } + +export class HomeAssistantFileIntegration extends FileIntegration {} diff --git a/ts/integrations/file/file.discovery.ts b/ts/integrations/file/file.discovery.ts new file mode 100644 index 0000000..74bdc6b --- /dev/null +++ b/ts/integrations/file/file.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fileProfile } from './file.types.js'; + +export const createFileDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fileProfile); diff --git a/ts/integrations/file/file.mapper.ts b/ts/integrations/file/file.mapper.ts new file mode 100644 index 0000000..687cf78 --- /dev/null +++ b/ts/integrations/file/file.mapper.ts @@ -0,0 +1,89 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFileConfig } from './file.types.js'; +import { fileDefaultName, fileProfile } from './file.types.js'; + +export class FileMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: fileProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IFileConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fileProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fileProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IFileConfig, rawDataArg: unknown): unknown { + if (isRecord(rawDataArg) && 'device' in rawDataArg && 'entities' in rawDataArg) { + return rawDataArg; + } + + const rawObject = isRecord(rawDataArg) ? rawDataArg : undefined; + const metadata = configArg.metadata || {}; + const filePath = stringValue(configArg.filePath) || stringValue(configArg.file_path) || stringValue(rawObject?.filePath) || stringValue(rawObject?.file_path) || stringValue(rawObject?.path) || stringValue(metadata.filePath) || stringValue(metadata.file_path); + const latestEntry = stringValue(rawObject?.latestEntry) || stringValue(rawObject?.latest_entry) || stringValue(rawObject?.lastLine) || stringValue(rawObject?.last_line) || stringValue(rawObject?.line) || stringValue(rawObject?.value) || primitiveString(rawDataArg); + if (latestEntry === undefined && !filePath) { + return rawDataArg; + } + + const configuredPlatform = stringValue(configArg.platform) || stringValue(rawObject?.platform) || stringValue(metadata.platform) || 'sensor'; + const name = configArg.name || stringValue(rawObject?.name) || (configuredPlatform === 'notify' ? 'File Notify' : fileDefaultName); + const unit = stringValue(configArg.unitOfMeasurement) || stringValue(configArg.unit_of_measurement) || stringValue(rawObject?.unit_of_measurement); + + return { + device: { + id: configArg.uniqueId || filePath || name, + name, + manufacturer: fileProfile.manufacturer, + model: configuredPlatform === 'notify' ? 'Local File Notify' : fileProfile.model, + protocol: fileProfile.defaultProtocol, + attributes: { + filePath, + configuredPlatform, + timestamp: configArg.timestamp ?? rawObject?.timestamp, + }, + }, + entities: [{ + id: 'latest_entry', + uniqueId: `${fileProfile.domain}_${SimpleLocalMapper.slug(filePath || name)}_latest_entry`, + name: configuredPlatform === 'notify' ? `${name} Target` : name, + platform: 'sensor', + state: latestEntry ?? null, + available: latestEntry !== undefined || Boolean(filePath), + writable: false, + unit, + attributes: { + filePath, + configuredPlatform, + valueTemplate: configArg.valueTemplate || configArg.value_template, + contentBytes: rawObject?.contentBytes, + }, + } satisfies ISimpleLocalEntitySnapshot], + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const primitiveString = (valueArg: unknown): string | undefined => valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg) ? String(valueArg) : undefined; + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; diff --git a/ts/integrations/file/file.types.ts b/ts/integrations/file/file.types.ts index 4b919de..a6b31ed 100644 --- a/ts/integrations/file/file.types.ts +++ b/ts/integrations/file/file.types.ts @@ -1,4 +1,85 @@ -export interface IHomeAssistantFileConfig { - // TODO: replace with the TypeScript-native config for file. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fileDomain = 'file'; +export const fileDefaultName = 'File'; + +export type TFilePlatform = 'sensor' | 'notify'; +export type TFileRawData = TSimpleLocalRawData; +export interface IFileSnapshot extends ISimpleLocalSnapshot {} +export interface IFileConfig extends ISimpleLocalConfig { + filePath?: string; + file_path?: string; + platform?: TFilePlatform | string; + unitOfMeasurement?: string; + unit_of_measurement?: string; + valueTemplate?: string; + value_template?: string; + timestamp?: boolean; } +export interface IHomeAssistantFileConfig extends IFileConfig {} + +export const fileProfile: ISimpleLocalIntegrationProfile = { + domain: 'file', + displayName: 'File', + manufacturer: 'Home Assistant', + model: 'Local File', + defaultName: fileDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'file', + 'file sensor', + 'local file', + 'read_file', + 'notify', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/file', + upstreamDomain: 'file', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'file-read-backwards==2.0.0', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@fabaff', + ], + configFlow: true, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'notify', + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local file path setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'read-only local file sensor snapshots from config.filePath or config.file_path', + ], + explicitUnsupported: [ + 'writing notification messages without injected client.execute or commandExecutor', + 'claiming live command success without injected client.execute or commandExecutor', + 'Home Assistant template rendering and YAML read_file decoding in the native runtime', + ], + }, + }, +}; diff --git a/ts/integrations/file/index.ts b/ts/integrations/file/index.ts index cac0e8a..a7f543d 100644 --- a/ts/integrations/file/index.ts +++ b/ts/integrations/file/index.ts @@ -1,2 +1,6 @@ +export * from './file.classes.client.js'; +export * from './file.classes.configflow.js'; export * from './file.classes.integration.js'; +export * from './file.discovery.js'; +export * from './file.mapper.js'; export * from './file.types.js'; diff --git a/ts/integrations/filesize/.generated-by-smarthome-exchange b/ts/integrations/filesize/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/filesize/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/filesize/filesize.classes.client.ts b/ts/integrations/filesize/filesize.classes.client.ts new file mode 100644 index 0000000..5635e27 --- /dev/null +++ b/ts/integrations/filesize/filesize.classes.client.ts @@ -0,0 +1,63 @@ +import * as plugins from '../../plugins.js'; +import { SimpleLocalClient } from '../../core/index.js'; +import { FilesizeMapper } from './filesize.mapper.js'; +import type { IFilesizeConfig, IFilesizeSnapshot } from './filesize.types.js'; +import { filesizeProfile } from './filesize.types.js'; + +export class FilesizeClient extends SimpleLocalClient { + constructor(private readonly configArg: IFilesizeConfig) { + super(filesizeProfile, configArg); + } + + public async getSnapshot(forceRefreshArg = false): Promise { + if (this.shouldStatLocalFile()) { + try { + return FilesizeMapper.toSnapshot({ config: this.configArg, rawData: await this.readLocalStats(), online: true, source: 'manual' }); + } catch (errorArg) { + return FilesizeMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: errorMessage(errorArg) }); + } + } + + const snapshot = await super.getSnapshot(forceRefreshArg); + if (snapshot.rawData === undefined && snapshot.entities.length) { + return FilesizeMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + return FilesizeMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error }); + } + + private shouldStatLocalFile(): boolean { + return Boolean(this.filePath() && !this.configArg.snapshot && this.configArg.rawData === undefined && this.configArg.rawText === undefined && !this.configArg.entities?.length && !this.configArg.client && !this.configArg.snapshotProvider && !this.configArg.host); + } + + private async readLocalStats(): Promise> { + const filePath = this.filePath(); + if (!filePath) { + throw new Error('File Size snapshots require config.filePath or config.file_path.'); + } + + const stat = await plugins.fs.stat(filePath); + if (!stat.isFile()) { + throw new Error(`File Size snapshots require a file path, not a directory: ${filePath}`); + } + + return { + filePath, + file: roundMegabytes(stat.size), + bytes: stat.size, + lastUpdated: stat.mtime.toISOString(), + created: stat.ctime.toISOString(), + }; + } + + private filePath(): string | undefined { + const metadata = this.configArg.metadata; + return stringValue(this.configArg.filePath) || stringValue(this.configArg.file_path) || stringValue(metadata?.filePath) || stringValue(metadata?.file_path); + } +} + +const roundMegabytes = (bytesArg: number): number => Math.round((bytesArg / 1e6) * 100) / 100; + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg); diff --git a/ts/integrations/filesize/filesize.classes.configflow.ts b/ts/integrations/filesize/filesize.classes.configflow.ts new file mode 100644 index 0000000..ca8a928 --- /dev/null +++ b/ts/integrations/filesize/filesize.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFilesizeConfig } from './filesize.types.js'; +import { filesizeProfile } from './filesize.types.js'; + +export class FilesizeConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(filesizeProfile); + } +} diff --git a/ts/integrations/filesize/filesize.classes.integration.ts b/ts/integrations/filesize/filesize.classes.integration.ts index f14a333..dce1038 100644 --- a/ts/integrations/filesize/filesize.classes.integration.ts +++ b/ts/integrations/filesize/filesize.classes.integration.ts @@ -1,23 +1,23 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js'; +import { FilesizeClient } from './filesize.classes.client.js'; +import { FilesizeConfigFlow } from './filesize.classes.configflow.js'; +import { createFilesizeDiscoveryDescriptor } from './filesize.discovery.js'; +import type { IFilesizeConfig } from './filesize.types.js'; +import { filesizeDomain, filesizeProfile } from './filesize.types.js'; + +export class FilesizeIntegration extends SimpleLocalIntegration { + public readonly domain = filesizeDomain; + public readonly discoveryDescriptor = createFilesizeDiscoveryDescriptor(); + public readonly configFlow = new FilesizeConfigFlow(); -export class HomeAssistantFilesizeIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "filesize", - displayName: "File Size", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/filesize", - "upstreamDomain": "filesize", - "iotClass": "local_polling", - "requirements": [], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@gjohansson-ST" - ] -}, - }); + super(filesizeProfile); + } + + public async setup(configArg: IFilesizeConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SimpleLocalRuntime(filesizeProfile, new FilesizeClient(configArg)); } } + +export class HomeAssistantFilesizeIntegration extends FilesizeIntegration {} diff --git a/ts/integrations/filesize/filesize.discovery.ts b/ts/integrations/filesize/filesize.discovery.ts new file mode 100644 index 0000000..2ebdcdb --- /dev/null +++ b/ts/integrations/filesize/filesize.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { filesizeProfile } from './filesize.types.js'; + +export const createFilesizeDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(filesizeProfile); diff --git a/ts/integrations/filesize/filesize.mapper.ts b/ts/integrations/filesize/filesize.mapper.ts new file mode 100644 index 0000000..88fa88d --- /dev/null +++ b/ts/integrations/filesize/filesize.mapper.ts @@ -0,0 +1,146 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFilesizeConfig } from './filesize.types.js'; +import { filesizeDefaultName, filesizeProfile } from './filesize.types.js'; + +export class FilesizeMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ + ...optionsArg, + profile: filesizeProfile, + rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData), + }); + } + + public static toSnapshotFromRaw(configArg: IFilesizeConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(filesizeProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(filesizeProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } + + private static normalizeRawData(configArg: IFilesizeConfig, rawDataArg: unknown): unknown { + if (isRecord(rawDataArg) && 'device' in rawDataArg && 'entities' in rawDataArg) { + return rawDataArg; + } + + const rawObject = isRecord(rawDataArg) ? rawDataArg : undefined; + const metadata = configArg.metadata || {}; + const filePath = stringValue(configArg.filePath) || stringValue(configArg.file_path) || stringValue(rawObject?.filePath) || stringValue(rawObject?.file_path) || stringValue(rawObject?.path) || stringValue(metadata.filePath) || stringValue(metadata.file_path); + const bytes = numberValue(rawObject?.bytes ?? rawObject?.size ?? rawObject?.st_size); + if (bytes === undefined && !filePath) { + return rawDataArg; + } + + const name = configArg.name || stringValue(rawObject?.name) || fileName(filePath) || filesizeDefaultName; + const megabytes = numberValue(rawObject?.file) ?? (bytes !== undefined ? roundMegabytes(bytes) : undefined); + const lastUpdated = timestampValue(rawObject?.lastUpdated ?? rawObject?.last_updated ?? rawObject?.mtime ?? rawObject?.modified); + const created = timestampValue(rawObject?.created ?? rawObject?.ctime); + const uniqueBase = SimpleLocalMapper.slug(filePath || name); + const entities: ISimpleLocalEntitySnapshot[] = [ + { + id: 'file', + uniqueId: `${filesizeProfile.domain}_${uniqueBase}_file`, + name: 'Size', + platform: 'sensor', + state: megabytes ?? null, + available: megabytes !== undefined, + writable: false, + unit: 'MB', + deviceClass: 'data_size', + stateClass: 'measurement', + }, + { + id: 'bytes', + uniqueId: `${filesizeProfile.domain}_${uniqueBase}_bytes`, + name: 'Size in bytes', + platform: 'sensor', + state: bytes ?? null, + available: bytes !== undefined, + writable: false, + unit: 'B', + deviceClass: 'data_size', + stateClass: 'measurement', + attributes: { + entityRegistryEnabledDefault: false, + entityCategory: 'diagnostic', + }, + }, + { + id: 'last_updated', + uniqueId: `${filesizeProfile.domain}_${uniqueBase}_last_updated`, + name: 'Last updated', + platform: 'sensor', + state: lastUpdated ?? null, + available: lastUpdated !== undefined, + writable: false, + deviceClass: 'timestamp', + attributes: { + entityRegistryEnabledDefault: false, + entityCategory: 'diagnostic', + }, + }, + { + id: 'created', + uniqueId: `${filesizeProfile.domain}_${uniqueBase}_created`, + name: 'Created', + platform: 'sensor', + state: created ?? null, + available: created !== undefined, + writable: false, + deviceClass: 'timestamp', + attributes: { + entityRegistryEnabledDefault: false, + entityCategory: 'diagnostic', + }, + }, + ]; + + return { + device: { + id: configArg.uniqueId || filePath || name, + name, + manufacturer: filesizeProfile.manufacturer, + model: filesizeProfile.model, + protocol: filesizeProfile.defaultProtocol, + attributes: { + filePath, + }, + }, + entities, + online: configArg.online ?? true, + updatedAt: new Date().toISOString(), + source: 'manual', + rawData: rawDataArg, + } satisfies ISimpleLocalSnapshot; + } +} + +const roundMegabytes = (bytesArg: number): number => Math.round((bytesArg / 1e6) * 100) / 100; + +const timestampValue = (valueArg: unknown): string | undefined => { + if (valueArg instanceof Date) { + return valueArg.toISOString(); + } + return stringValue(valueArg); +}; + +const numberValue = (valueArg: unknown): number | undefined => { + const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' && valueArg.trim() ? Number(valueArg) : undefined; + return value !== undefined && Number.isFinite(value) ? value : undefined; +}; + +const fileName = (valueArg: string | undefined): string | undefined => valueArg?.split(/[\\/]/).filter(Boolean).pop(); + +const isRecord = (valueArg: unknown): valueArg is Record => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; diff --git a/ts/integrations/filesize/filesize.types.ts b/ts/integrations/filesize/filesize.types.ts index a6c56ca..d9ac5ac 100644 --- a/ts/integrations/filesize/filesize.types.ts +++ b/ts/integrations/filesize/filesize.types.ts @@ -1,4 +1,76 @@ -export interface IHomeAssistantFilesizeConfig { - // TODO: replace with the TypeScript-native config for filesize. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const filesizeDomain = 'filesize'; +export const filesizeDefaultName = 'File Size'; + +export type TFilesizeRawData = TSimpleLocalRawData; +export interface IFilesizeSnapshot extends ISimpleLocalSnapshot {} +export interface IFilesizeConfig extends ISimpleLocalConfig { + filePath?: string; + file_path?: string; } +export interface IHomeAssistantFilesizeConfig extends IFilesizeConfig {} + +export const filesizeProfile: ISimpleLocalIntegrationProfile = { + domain: 'filesize', + displayName: 'File Size', + manufacturer: 'Home Assistant', + model: 'Local File Statistics', + defaultName: filesizeDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'filesize', + 'file size', + 'local file', + 'stat', + 'bytes', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/filesize', + upstreamDomain: 'filesize', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@gjohansson-ST', + ], + configFlow: true, + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local file path setup, snapshots, raw data, snapshotProvider, and injected native clients', + 'read-only local file stat snapshots from config.filePath or config.file_path', + 'Home Assistant filesize size, bytes, last_updated, and created sensor mapping', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'watch subscriptions and push updates without an injected native client', + 'Home Assistant allowlist enforcement outside caller-provided safe local paths', + ], + }, + }, +}; diff --git a/ts/integrations/filesize/index.ts b/ts/integrations/filesize/index.ts index 01dee41..4047c63 100644 --- a/ts/integrations/filesize/index.ts +++ b/ts/integrations/filesize/index.ts @@ -1,2 +1,6 @@ +export * from './filesize.classes.client.js'; +export * from './filesize.classes.configflow.js'; export * from './filesize.classes.integration.js'; +export * from './filesize.discovery.js'; +export * from './filesize.mapper.js'; export * from './filesize.types.js'; diff --git a/ts/integrations/fing/.generated-by-smarthome-exchange b/ts/integrations/fing/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fing/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/fing/fing.classes.client.ts b/ts/integrations/fing/fing.classes.client.ts new file mode 100644 index 0000000..5bf0f07 --- /dev/null +++ b/ts/integrations/fing/fing.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFingConfig } from './fing.types.js'; +import { fingProfile } from './fing.types.js'; + +export class FingClient extends SimpleLocalClient { + constructor(configArg: IFingConfig) { + super(fingProfile, configArg); + } +} diff --git a/ts/integrations/fing/fing.classes.configflow.ts b/ts/integrations/fing/fing.classes.configflow.ts new file mode 100644 index 0000000..86b3d13 --- /dev/null +++ b/ts/integrations/fing/fing.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFingConfig } from './fing.types.js'; +import { fingProfile } from './fing.types.js'; + +export class FingConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fingProfile); + } +} diff --git a/ts/integrations/fing/fing.classes.integration.ts b/ts/integrations/fing/fing.classes.integration.ts index d769d98..ea8e598 100644 --- a/ts/integrations/fing/fing.classes.integration.ts +++ b/ts/integrations/fing/fing.classes.integration.ts @@ -1,27 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FingConfigFlow } from './fing.classes.configflow.js'; +import { createFingDiscoveryDescriptor } from './fing.discovery.js'; +import type { IFingConfig } from './fing.types.js'; +import { fingDomain, fingProfile } from './fing.types.js'; + +export class FingIntegration extends SimpleLocalIntegration { + public readonly domain = fingDomain; + public readonly discoveryDescriptor = createFingDiscoveryDescriptor(); + public readonly configFlow = new FingConfigFlow(); -export class HomeAssistantFingIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "fing", - displayName: "Fing", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fing", - "upstreamDomain": "fing", - "integrationType": "service", - "iotClass": "local_polling", - "qualityScale": "bronze", - "requirements": [ - "fing_agent_api==1.1.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@Lorenzo-Gasparini" - ] -}, - }); + super(fingProfile); } } + +export class HomeAssistantFingIntegration extends FingIntegration {} diff --git a/ts/integrations/fing/fing.discovery.ts b/ts/integrations/fing/fing.discovery.ts new file mode 100644 index 0000000..9c1cb98 --- /dev/null +++ b/ts/integrations/fing/fing.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fingProfile } from './fing.types.js'; + +export const createFingDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fingProfile); diff --git a/ts/integrations/fing/fing.mapper.ts b/ts/integrations/fing/fing.mapper.ts new file mode 100644 index 0000000..3fca1e5 --- /dev/null +++ b/ts/integrations/fing/fing.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFingConfig } from './fing.types.js'; +import { fingProfile } from './fing.types.js'; + +export class FingMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: fingProfile }); + } + + public static toSnapshotFromRaw(configArg: IFingConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: fingProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fingProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fingProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/fing/fing.types.ts b/ts/integrations/fing/fing.types.ts index 02f05ee..97e60bf 100644 --- a/ts/integrations/fing/fing.types.ts +++ b/ts/integrations/fing/fing.types.ts @@ -1,4 +1,85 @@ -export interface IHomeAssistantFingConfig { - // TODO: replace with the TypeScript-native config for fing. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fingDomain = 'fing'; +export const fingDefaultName = 'Fing'; + +export type TFingRawData = TSimpleLocalRawData; +export interface IFingSnapshot extends ISimpleLocalSnapshot {} +export interface IFingConfig extends ISimpleLocalConfig { + ipAddress?: string; + apiKey?: string; + upnpAvailable?: boolean; } +export interface IHomeAssistantFingConfig extends IFingConfig {} + +export const fingProfile: ISimpleLocalIntegrationProfile = { + domain: 'fing', + displayName: 'Fing', + manufacturer: 'Fing', + model: 'Fing Agent', + defaultName: fingDefaultName, + defaultPort: 49090, + defaultProtocol: 'http', + status: 'read-only-runtime', + platforms: [ + 'binary_sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'fing', + 'fing agent', + 'network inventory', + 'device tracker', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fing', + upstreamDomain: 'fing', + integrationType: 'service', + iotClass: 'local_polling', + qualityScale: 'bronze', + qualityScaleRulesPath: 'homeassistant/components/fing/quality_scale.yaml', + requirements: [ + 'fing_agent_api==1.1.0', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@Lorenzo-Gasparini', + ], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/fing', + homeAssistantPlatforms: [ + 'device_tracker', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'binary_sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local setup for Fing Agent host, port, API key, snapshots, raw data, snapshotProvider, and injected native clients', + 'generic HTTP local transport when config.path points at a Fing Agent JSON endpoint', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'direct fing_agent_api polling when no local path, injected client, snapshotProvider, snapshot, entities, or rawData is supplied', + 'UPnP agent-info probing without an injected native client', + ], + }, + }, +}; diff --git a/ts/integrations/fing/index.ts b/ts/integrations/fing/index.ts index 128e041..80ce62f 100644 --- a/ts/integrations/fing/index.ts +++ b/ts/integrations/fing/index.ts @@ -1,2 +1,6 @@ +export * from './fing.classes.client.js'; +export * from './fing.classes.configflow.js'; export * from './fing.classes.integration.js'; +export * from './fing.discovery.js'; +export * from './fing.mapper.js'; export * from './fing.types.js'; diff --git a/ts/integrations/firefly_iii/.generated-by-smarthome-exchange b/ts/integrations/firefly_iii/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/firefly_iii/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/firefly_iii/firefly_iii.classes.client.ts b/ts/integrations/firefly_iii/firefly_iii.classes.client.ts new file mode 100644 index 0000000..45f2f1f --- /dev/null +++ b/ts/integrations/firefly_iii/firefly_iii.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFireflyIiiConfig } from './firefly_iii.types.js'; +import { fireflyIiiProfile } from './firefly_iii.types.js'; + +export class FireflyIiiClient extends SimpleLocalClient { + constructor(configArg: IFireflyIiiConfig) { + super(fireflyIiiProfile, configArg); + } +} diff --git a/ts/integrations/firefly_iii/firefly_iii.classes.configflow.ts b/ts/integrations/firefly_iii/firefly_iii.classes.configflow.ts new file mode 100644 index 0000000..c3d1aa4 --- /dev/null +++ b/ts/integrations/firefly_iii/firefly_iii.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFireflyIiiConfig } from './firefly_iii.types.js'; +import { fireflyIiiProfile } from './firefly_iii.types.js'; + +export class FireflyIiiConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fireflyIiiProfile); + } +} diff --git a/ts/integrations/firefly_iii/firefly_iii.classes.integration.ts b/ts/integrations/firefly_iii/firefly_iii.classes.integration.ts index 8801c6a..ed70548 100644 --- a/ts/integrations/firefly_iii/firefly_iii.classes.integration.ts +++ b/ts/integrations/firefly_iii/firefly_iii.classes.integration.ts @@ -1,27 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FireflyIiiConfigFlow } from './firefly_iii.classes.configflow.js'; +import { createFireflyIiiDiscoveryDescriptor } from './firefly_iii.discovery.js'; +import type { IFireflyIiiConfig } from './firefly_iii.types.js'; +import { fireflyIiiDomain, fireflyIiiProfile } from './firefly_iii.types.js'; + +export class FireflyIiiIntegration extends SimpleLocalIntegration { + public readonly domain = fireflyIiiDomain; + public readonly discoveryDescriptor = createFireflyIiiDiscoveryDescriptor(); + public readonly configFlow = new FireflyIiiConfigFlow(); -export class HomeAssistantFireflyIiiIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "firefly_iii", - displayName: "Firefly III", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/firefly_iii", - "upstreamDomain": "firefly_iii", - "integrationType": "service", - "iotClass": "local_polling", - "qualityScale": "bronze", - "requirements": [ - "pyfirefly==0.1.12" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@erwindouna" - ] -}, - }); + super(fireflyIiiProfile); } } + +export class HomeAssistantFireflyIiiIntegration extends FireflyIiiIntegration {} diff --git a/ts/integrations/firefly_iii/firefly_iii.discovery.ts b/ts/integrations/firefly_iii/firefly_iii.discovery.ts new file mode 100644 index 0000000..2c8d697 --- /dev/null +++ b/ts/integrations/firefly_iii/firefly_iii.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fireflyIiiProfile } from './firefly_iii.types.js'; + +export const createFireflyIiiDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fireflyIiiProfile); diff --git a/ts/integrations/firefly_iii/firefly_iii.mapper.ts b/ts/integrations/firefly_iii/firefly_iii.mapper.ts new file mode 100644 index 0000000..74a5279 --- /dev/null +++ b/ts/integrations/firefly_iii/firefly_iii.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFireflyIiiConfig } from './firefly_iii.types.js'; +import { fireflyIiiProfile } from './firefly_iii.types.js'; + +export class FireflyIiiMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: fireflyIiiProfile }); + } + + public static toSnapshotFromRaw(configArg: IFireflyIiiConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: fireflyIiiProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fireflyIiiProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fireflyIiiProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/firefly_iii/firefly_iii.types.ts b/ts/integrations/firefly_iii/firefly_iii.types.ts index 1c16bd1..88b50fe 100644 --- a/ts/integrations/firefly_iii/firefly_iii.types.ts +++ b/ts/integrations/firefly_iii/firefly_iii.types.ts @@ -1,4 +1,84 @@ -export interface IHomeAssistantFireflyIiiConfig { - // TODO: replace with the TypeScript-native config for firefly_iii. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fireflyIiiDomain = 'firefly_iii'; +export const fireflyIiiDefaultName = 'Firefly III'; + +export type TFireflyIiiRawData = TSimpleLocalRawData; +export interface IFireflyIiiSnapshot extends ISimpleLocalSnapshot {} +export interface IFireflyIiiConfig extends ISimpleLocalConfig { + url?: string; + apiKey?: string; + verifySsl?: boolean; } +export interface IHomeAssistantFireflyIiiConfig extends IFireflyIiiConfig {} + +export const fireflyIiiProfile: ISimpleLocalIntegrationProfile = { + domain: 'firefly_iii', + displayName: 'Firefly III', + manufacturer: 'Firefly III', + model: 'Firefly III', + defaultName: fireflyIiiDefaultName, + status: 'read-only-runtime', + platforms: [ + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'firefly', + 'firefly iii', + 'finance', + 'budget', + 'pyfirefly', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/firefly_iii', + upstreamDomain: 'firefly_iii', + integrationType: 'service', + iotClass: 'local_polling', + qualityScale: 'bronze', + qualityScaleRulesPath: 'homeassistant/components/firefly_iii/quality_scale.yaml', + requirements: [ + 'pyfirefly==0.1.12', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@erwindouna', + ], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/firefly_iii', + homeAssistantPlatforms: [ + 'sensor', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local setup for Firefly III URL, API key, snapshots, raw data, snapshotProvider, and injected native clients', + 'generic HTTP local transport when config.path points at a Firefly III JSON endpoint', + ], + explicitUnsupported: [ + 'claiming live command success without injected client.execute or commandExecutor', + 'direct pyfirefly polling when no local path, injected client, snapshotProvider, snapshot, entities, or rawData is supplied', + 'Firefly III write operations and account administration', + ], + }, + }, +}; diff --git a/ts/integrations/firefly_iii/index.ts b/ts/integrations/firefly_iii/index.ts index afabba8..1d85f1c 100644 --- a/ts/integrations/firefly_iii/index.ts +++ b/ts/integrations/firefly_iii/index.ts @@ -1,2 +1,6 @@ +export * from './firefly_iii.classes.client.js'; +export * from './firefly_iii.classes.configflow.js'; export * from './firefly_iii.classes.integration.js'; +export * from './firefly_iii.discovery.js'; +export * from './firefly_iii.mapper.js'; export * from './firefly_iii.types.js'; diff --git a/ts/integrations/firmata/.generated-by-smarthome-exchange b/ts/integrations/firmata/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/firmata/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/firmata/firmata.classes.client.ts b/ts/integrations/firmata/firmata.classes.client.ts new file mode 100644 index 0000000..d389124 --- /dev/null +++ b/ts/integrations/firmata/firmata.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFirmataConfig } from './firmata.types.js'; +import { firmataProfile } from './firmata.types.js'; + +export class FirmataClient extends SimpleLocalClient { + constructor(configArg: IFirmataConfig) { + super(firmataProfile, configArg); + } +} diff --git a/ts/integrations/firmata/firmata.classes.configflow.ts b/ts/integrations/firmata/firmata.classes.configflow.ts new file mode 100644 index 0000000..9f40450 --- /dev/null +++ b/ts/integrations/firmata/firmata.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFirmataConfig } from './firmata.types.js'; +import { firmataProfile } from './firmata.types.js'; + +export class FirmataConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(firmataProfile); + } +} diff --git a/ts/integrations/firmata/firmata.classes.integration.ts b/ts/integrations/firmata/firmata.classes.integration.ts index d4f2b50..14f67a5 100644 --- a/ts/integrations/firmata/firmata.classes.integration.ts +++ b/ts/integrations/firmata/firmata.classes.integration.ts @@ -1,26 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FirmataConfigFlow } from './firmata.classes.configflow.js'; +import { createFirmataDiscoveryDescriptor } from './firmata.discovery.js'; +import type { IFirmataConfig } from './firmata.types.js'; +import { firmataDomain, firmataProfile } from './firmata.types.js'; + +export class FirmataIntegration extends SimpleLocalIntegration { + public readonly domain = firmataDomain; + public readonly discoveryDescriptor = createFirmataDiscoveryDescriptor(); + public readonly configFlow = new FirmataConfigFlow(); -export class HomeAssistantFirmataIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "firmata", - displayName: "Firmata", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/firmata", - "upstreamDomain": "firmata", - "iotClass": "local_push", - "qualityScale": "legacy", - "requirements": [ - "pymata-express==1.19" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@DaAwesomeP" - ] -}, - }); + super(firmataProfile); } } + +export class HomeAssistantFirmataIntegration extends FirmataIntegration {} diff --git a/ts/integrations/firmata/firmata.discovery.ts b/ts/integrations/firmata/firmata.discovery.ts new file mode 100644 index 0000000..3085441 --- /dev/null +++ b/ts/integrations/firmata/firmata.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { firmataProfile } from './firmata.types.js'; + +export const createFirmataDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(firmataProfile); diff --git a/ts/integrations/firmata/firmata.mapper.ts b/ts/integrations/firmata/firmata.mapper.ts new file mode 100644 index 0000000..13316e9 --- /dev/null +++ b/ts/integrations/firmata/firmata.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFirmataConfig } from './firmata.types.js'; +import { firmataProfile } from './firmata.types.js'; + +export class FirmataMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: firmataProfile }); + } + + public static toSnapshotFromRaw(configArg: IFirmataConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: firmataProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(firmataProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(firmataProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/firmata/firmata.types.ts b/ts/integrations/firmata/firmata.types.ts index ca3d467..ae71124 100644 --- a/ts/integrations/firmata/firmata.types.ts +++ b/ts/integrations/firmata/firmata.types.ts @@ -1,4 +1,127 @@ -export interface IHomeAssistantFirmataConfig { - // TODO: replace with the TypeScript-native config for firmata. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const firmataDomain = 'firmata'; +export const firmataDefaultName = 'Firmata'; + +export type TFirmataRawData = TSimpleLocalRawData; +export type TFirmataPinMode = 'ANALOG' | 'OUTPUT' | 'PWM' | 'INPUT' | 'PULLUP' | string; +export type TFirmataPin = number | string; + +export interface IFirmataPinConfig { + name: string; + pin: TFirmataPin; + pinMode: TFirmataPinMode; + initial?: boolean | number; + negate?: boolean; + minimum?: number; + maximum?: number; + differential?: number; } + +export interface IFirmataSnapshot extends ISimpleLocalSnapshot {} +export interface IFirmataConfig extends ISimpleLocalConfig { + serialPort?: string; + serialBaudRate?: number; + arduinoInstanceId?: number; + arduinoWait?: number; + sleepTune?: number; + samplingInterval?: number; + switches?: IFirmataPinConfig[]; + lights?: IFirmataPinConfig[]; + binarySensors?: IFirmataPinConfig[]; + sensors?: IFirmataPinConfig[]; +} +export interface IHomeAssistantFirmataConfig extends IFirmataConfig {} + +export const firmataControlServices = [ + 'turn_on', + 'turn_off', + 'toggle', + 'set_level', +]; + +export const firmataProfile: ISimpleLocalIntegrationProfile = { + domain: 'firmata', + displayName: 'Firmata', + manufacturer: 'Firmata', + model: 'Firmata board', + defaultName: firmataDefaultName, + defaultProtocol: 'local', + status: 'control-runtime', + platforms: [ + 'binary_sensor', + 'light', + 'sensor', + 'switch', + ], + serviceDomains: [ + 'light', + 'switch', + ], + controlServices: firmataControlServices, + discoverySources: [ + 'manual', + 'usb', + 'custom', + ], + discoveryKeywords: [ + 'firmata', + 'arduino', + 'pymata', + 'serial', + 'microcontroller', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/firmata', + upstreamDomain: 'firmata', + iotClass: 'local_push', + qualityScale: 'legacy', + requirements: [ + 'pymata-express==1.19', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@DaAwesomeP', + ], + configFlow: false, + documentation: 'https://www.home-assistant.io/integrations/firmata', + loggers: [ + 'pymata_express', + ], + homeAssistantPlatforms: [ + 'binary_sensor', + 'light', + 'sensor', + 'switch', + ], + runtime: { + type: 'control-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ...firmataControlServices, + ], + platforms: [ + 'binary_sensor', + 'light', + 'sensor', + 'switch', + ], + controls: true, + }, + localApi: { + implemented: [ + 'manual serial board setup data, snapshots, raw data, snapshotProvider, and injected native clients', + 'executor-gated Firmata switch and light service dispatch', + ], + explicitUnsupported: [ + 'claiming pin output success without injected client.execute or commandExecutor', + 'opening pymata-express serial sessions without an injected native client', + 'pin reservation, schema validation, and callback reporting beyond provided snapshot/rawData/client inputs', + ], + }, + }, +}; diff --git a/ts/integrations/firmata/index.ts b/ts/integrations/firmata/index.ts index becdd25..ee712cc 100644 --- a/ts/integrations/firmata/index.ts +++ b/ts/integrations/firmata/index.ts @@ -1,2 +1,6 @@ +export * from './firmata.classes.client.js'; +export * from './firmata.classes.configflow.js'; export * from './firmata.classes.integration.js'; +export * from './firmata.discovery.js'; +export * from './firmata.mapper.js'; export * from './firmata.types.js'; diff --git a/ts/integrations/fivem/.generated-by-smarthome-exchange b/ts/integrations/fivem/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fivem/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/fivem/fivem.classes.client.ts b/ts/integrations/fivem/fivem.classes.client.ts new file mode 100644 index 0000000..f2a97f6 --- /dev/null +++ b/ts/integrations/fivem/fivem.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFivemConfig } from './fivem.types.js'; +import { fivemProfile } from './fivem.types.js'; + +export class FivemClient extends SimpleLocalClient { + constructor(configArg: IFivemConfig) { + super(fivemProfile, configArg); + } +} diff --git a/ts/integrations/fivem/fivem.classes.configflow.ts b/ts/integrations/fivem/fivem.classes.configflow.ts new file mode 100644 index 0000000..ab13d5a --- /dev/null +++ b/ts/integrations/fivem/fivem.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFivemConfig } from './fivem.types.js'; +import { fivemProfile } from './fivem.types.js'; + +export class FivemConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fivemProfile); + } +} diff --git a/ts/integrations/fivem/fivem.classes.integration.ts b/ts/integrations/fivem/fivem.classes.integration.ts index fe1d685..3d7ab8d 100644 --- a/ts/integrations/fivem/fivem.classes.integration.ts +++ b/ts/integrations/fivem/fivem.classes.integration.ts @@ -1,26 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FivemConfigFlow } from './fivem.classes.configflow.js'; +import { createFivemDiscoveryDescriptor } from './fivem.discovery.js'; +import type { IFivemConfig } from './fivem.types.js'; +import { fivemDomain, fivemProfile } from './fivem.types.js'; + +export class FivemIntegration extends SimpleLocalIntegration { + public readonly domain = fivemDomain; + public readonly discoveryDescriptor = createFivemDiscoveryDescriptor(); + public readonly configFlow = new FivemConfigFlow(); -export class HomeAssistantFivemIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "fivem", - displayName: "FiveM", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fivem", - "upstreamDomain": "fivem", - "integrationType": "service", - "iotClass": "local_polling", - "requirements": [ - "fivem-api==0.1.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@Sander0542" - ] -}, - }); + super(fivemProfile); } } + +export class HomeAssistantFivemIntegration extends FivemIntegration {} diff --git a/ts/integrations/fivem/fivem.discovery.ts b/ts/integrations/fivem/fivem.discovery.ts new file mode 100644 index 0000000..ed9b554 --- /dev/null +++ b/ts/integrations/fivem/fivem.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fivemProfile } from './fivem.types.js'; + +export const createFivemDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fivemProfile); diff --git a/ts/integrations/fivem/fivem.mapper.ts b/ts/integrations/fivem/fivem.mapper.ts new file mode 100644 index 0000000..db97edf --- /dev/null +++ b/ts/integrations/fivem/fivem.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFivemConfig } from './fivem.types.js'; +import { fivemProfile } from './fivem.types.js'; + +export class FivemMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: fivemProfile }); + } + + public static toSnapshotFromRaw(configArg: IFivemConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: fivemProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fivemProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fivemProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/fivem/fivem.types.ts b/ts/integrations/fivem/fivem.types.ts index bd4c3e3..4d2765f 100644 --- a/ts/integrations/fivem/fivem.types.ts +++ b/ts/integrations/fivem/fivem.types.ts @@ -1,4 +1,96 @@ -export interface IHomeAssistantFivemConfig { - // TODO: replace with the TypeScript-native config for fivem. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fivemDomain = 'fivem'; +export const fivemDefaultName = 'FiveM'; +export const fivemDefaultPort = 30120; +export const fivemDefaultHttpPath = '/dynamic.json'; + +export type TFivemRawData = TSimpleLocalRawData; +export interface IFivemSnapshot extends ISimpleLocalSnapshot {} +export interface IFivemConfig extends ISimpleLocalConfig { + gameName?: string; } +export interface IHomeAssistantFivemConfig extends IFivemConfig {} + +export const fivemProfile: ISimpleLocalIntegrationProfile = { + domain: 'fivem', + displayName: 'FiveM', + manufacturer: 'Cfx.re', + model: 'FXServer', + defaultName: fivemDefaultName, + defaultPort: fivemDefaultPort, + defaultHttpPath: fivemDefaultHttpPath, + defaultProtocol: 'http', + status: 'read-only-runtime', + platforms: [ + 'binary_sensor', + 'sensor', + ], + serviceDomains: [], + controlServices: [], + discoverySources: [ + 'manual', + 'http', + 'custom', + ], + discoveryKeywords: [ + 'fivem', + 'fxserver', + 'cfx.re', + 'gta5', + 'players', + 'resources', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fivem', + upstreamDomain: 'fivem', + integrationType: 'service', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'fivem-api==0.1.2', + ], + dependencies: [], + afterDependencies: [], + codeowners: [ + '@Sander0542', + ], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/fivem', + defaults: { + port: fivemDefaultPort, + httpPath: fivemDefaultHttpPath, + scanIntervalSeconds: 60, + }, + platforms: [ + 'binary_sensor', + 'sensor', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + ], + platforms: [ + 'binary_sensor', + 'sensor', + ], + controls: false, + }, + localApi: { + implemented: [ + 'manual local setup for FiveM host, port, snapshots, raw data, snapshotProvider, and injected native clients', + 'generic HTTP local snapshot transport for FXServer JSON endpoints such as dynamic.json when config.path is provided', + 'runtime device and entity mapping for status, players, and resources snapshots', + ], + explicitUnsupported: [ + 'RCon/server command execution without an injected client.execute or commandExecutor', + 'claiming live command success without an injected client.execute or commandExecutor', + 'live gamename validation when no reachable local endpoint or injected client is configured', + ], + }, + }, +}; diff --git a/ts/integrations/fivem/index.ts b/ts/integrations/fivem/index.ts index 2a2b0c9..177210d 100644 --- a/ts/integrations/fivem/index.ts +++ b/ts/integrations/fivem/index.ts @@ -1,2 +1,6 @@ +export * from './fivem.classes.client.js'; +export * from './fivem.classes.configflow.js'; export * from './fivem.classes.integration.js'; +export * from './fivem.discovery.js'; +export * from './fivem.mapper.js'; export * from './fivem.types.js'; diff --git a/ts/integrations/fjaraskupan/.generated-by-smarthome-exchange b/ts/integrations/fjaraskupan/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fjaraskupan/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/fjaraskupan/fjaraskupan.classes.client.ts b/ts/integrations/fjaraskupan/fjaraskupan.classes.client.ts new file mode 100644 index 0000000..d95efd8 --- /dev/null +++ b/ts/integrations/fjaraskupan/fjaraskupan.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFjaraskupanConfig } from './fjaraskupan.types.js'; +import { fjaraskupanProfile } from './fjaraskupan.types.js'; + +export class FjaraskupanClient extends SimpleLocalClient { + constructor(configArg: IFjaraskupanConfig) { + super(fjaraskupanProfile, configArg); + } +} diff --git a/ts/integrations/fjaraskupan/fjaraskupan.classes.configflow.ts b/ts/integrations/fjaraskupan/fjaraskupan.classes.configflow.ts new file mode 100644 index 0000000..1ce46da --- /dev/null +++ b/ts/integrations/fjaraskupan/fjaraskupan.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFjaraskupanConfig } from './fjaraskupan.types.js'; +import { fjaraskupanProfile } from './fjaraskupan.types.js'; + +export class FjaraskupanConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(fjaraskupanProfile); + } +} diff --git a/ts/integrations/fjaraskupan/fjaraskupan.classes.integration.ts b/ts/integrations/fjaraskupan/fjaraskupan.classes.integration.ts index 76771b7..84ed879 100644 --- a/ts/integrations/fjaraskupan/fjaraskupan.classes.integration.ts +++ b/ts/integrations/fjaraskupan/fjaraskupan.classes.integration.ts @@ -1,28 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FjaraskupanConfigFlow } from './fjaraskupan.classes.configflow.js'; +import { createFjaraskupanDiscoveryDescriptor } from './fjaraskupan.discovery.js'; +import type { IFjaraskupanConfig } from './fjaraskupan.types.js'; +import { fjaraskupanDomain, fjaraskupanProfile } from './fjaraskupan.types.js'; + +export class FjaraskupanIntegration extends SimpleLocalIntegration { + public readonly domain = fjaraskupanDomain; + public readonly discoveryDescriptor = createFjaraskupanDiscoveryDescriptor(); + public readonly configFlow = new FjaraskupanConfigFlow(); -export class HomeAssistantFjaraskupanIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "fjaraskupan", - displayName: "Fjäråskupan", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fjaraskupan", - "upstreamDomain": "fjaraskupan", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "fjaraskupan==2.3.3" - ], - "dependencies": [ - "bluetooth_adapters" - ], - "afterDependencies": [], - "codeowners": [ - "@elupus" - ] -}, - }); + super(fjaraskupanProfile); } } + +export class HomeAssistantFjaraskupanIntegration extends FjaraskupanIntegration {} diff --git a/ts/integrations/fjaraskupan/fjaraskupan.discovery.ts b/ts/integrations/fjaraskupan/fjaraskupan.discovery.ts new file mode 100644 index 0000000..53f6da2 --- /dev/null +++ b/ts/integrations/fjaraskupan/fjaraskupan.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { fjaraskupanProfile } from './fjaraskupan.types.js'; + +export const createFjaraskupanDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(fjaraskupanProfile); diff --git a/ts/integrations/fjaraskupan/fjaraskupan.mapper.ts b/ts/integrations/fjaraskupan/fjaraskupan.mapper.ts new file mode 100644 index 0000000..5c72e96 --- /dev/null +++ b/ts/integrations/fjaraskupan/fjaraskupan.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFjaraskupanConfig } from './fjaraskupan.types.js'; +import { fjaraskupanProfile } from './fjaraskupan.types.js'; + +export class FjaraskupanMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: fjaraskupanProfile }); + } + + public static toSnapshotFromRaw(configArg: IFjaraskupanConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: fjaraskupanProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(fjaraskupanProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(fjaraskupanProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/fjaraskupan/fjaraskupan.types.ts b/ts/integrations/fjaraskupan/fjaraskupan.types.ts index 0c7adde..7dbc788 100644 --- a/ts/integrations/fjaraskupan/fjaraskupan.types.ts +++ b/ts/integrations/fjaraskupan/fjaraskupan.types.ts @@ -1,4 +1,120 @@ -export interface IHomeAssistantFjaraskupanConfig { - // TODO: replace with the TypeScript-native config for fjaraskupan. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const fjaraskupanDomain = 'fjaraskupan'; +export const fjaraskupanDefaultName = 'Fjaraskupan'; + +export type TFjaraskupanRawData = TSimpleLocalRawData; +export interface IFjaraskupanSnapshot extends ISimpleLocalSnapshot {} +export interface IFjaraskupanConfig extends ISimpleLocalConfig { + address?: string; + macAddress?: string; } +export interface IHomeAssistantFjaraskupanConfig extends IFjaraskupanConfig {} + +export const fjaraskupanProfile: ISimpleLocalIntegrationProfile = { + domain: 'fjaraskupan', + displayName: 'Fjaraskupan', + manufacturer: 'Fjaraskupan', + model: 'Bluetooth cooker hood', + defaultName: fjaraskupanDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'binary_sensor', + 'fan', + 'light', + 'number', + 'sensor', + ], + serviceDomains: [ + 'fan', + 'light', + 'number', + ], + controlServices: [ + 'set_brightness', + 'set_percentage', + 'set_preset_mode', + 'set_value', + 'turn_off', + 'turn_on', + ], + discoverySources: [ + 'manual', + 'bluetooth', + 'custom', + ], + discoveryKeywords: [ + 'fjaraskupan', + 'odfjar', + 'bluetooth', + 'cooker hood', + 'range hood', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fjaraskupan', + upstreamDomain: 'fjaraskupan', + integrationType: 'hub', + iotClass: 'local_polling', + qualityScale: undefined, + requirements: [ + 'fjaraskupan==2.3.3', + ], + dependencies: [ + 'bluetooth_adapters', + ], + afterDependencies: [], + codeowners: [ + '@elupus', + ], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/fjaraskupan', + bluetooth: { + manufacturerId: 20296, + manufacturerDataStart: [79, 68, 70, 74, 65, 82], + connectable: false, + }, + platforms: [ + 'binary_sensor', + 'fan', + 'light', + 'number', + 'sensor', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'set_brightness', + 'set_percentage', + 'set_preset_mode', + 'set_value', + 'turn_off', + 'turn_on', + ], + platforms: [ + 'binary_sensor', + 'fan', + 'light', + 'number', + 'sensor', + ], + controls: 'delegated only to an injected client.execute or commandExecutor', + }, + localApi: { + implemented: [ + 'manual local setup for Bluetooth address metadata, snapshots, raw data, snapshotProvider, and injected native clients', + 'runtime device and entity mapping for fan, light, number, diagnostic sensor, and filter binary sensor snapshots', + 'safe control delegation to injected client.execute or commandExecutor only', + ], + explicitUnsupported: [ + 'native Bluetooth LE connection and fjaraskupan command transport in this TypeScript package', + 'claiming fan, light, or periodic venting write success without an injected client.execute or commandExecutor', + 'Home Assistant Bluetooth rediscovery callbacks and live advertisement subscriptions', + ], + }, + }, +}; diff --git a/ts/integrations/fjaraskupan/index.ts b/ts/integrations/fjaraskupan/index.ts index d11f526..2ab4b73 100644 --- a/ts/integrations/fjaraskupan/index.ts +++ b/ts/integrations/fjaraskupan/index.ts @@ -1,2 +1,6 @@ +export * from './fjaraskupan.classes.client.js'; +export * from './fjaraskupan.classes.configflow.js'; export * from './fjaraskupan.classes.integration.js'; +export * from './fjaraskupan.discovery.js'; +export * from './fjaraskupan.mapper.js'; export * from './fjaraskupan.types.js'; diff --git a/ts/integrations/flexit/.generated-by-smarthome-exchange b/ts/integrations/flexit/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/flexit/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/flexit/flexit.classes.client.ts b/ts/integrations/flexit/flexit.classes.client.ts new file mode 100644 index 0000000..b25019a --- /dev/null +++ b/ts/integrations/flexit/flexit.classes.client.ts @@ -0,0 +1,9 @@ +import { SimpleLocalClient } from '../../core/index.js'; +import type { IFlexitConfig } from './flexit.types.js'; +import { flexitProfile } from './flexit.types.js'; + +export class FlexitClient extends SimpleLocalClient { + constructor(configArg: IFlexitConfig) { + super(flexitProfile, configArg); + } +} diff --git a/ts/integrations/flexit/flexit.classes.configflow.ts b/ts/integrations/flexit/flexit.classes.configflow.ts new file mode 100644 index 0000000..83021cf --- /dev/null +++ b/ts/integrations/flexit/flexit.classes.configflow.ts @@ -0,0 +1,9 @@ +import { SimpleLocalConfigFlow } from '../../core/index.js'; +import type { IFlexitConfig } from './flexit.types.js'; +import { flexitProfile } from './flexit.types.js'; + +export class FlexitConfigFlow extends SimpleLocalConfigFlow { + constructor() { + super(flexitProfile); + } +} diff --git a/ts/integrations/flexit/flexit.classes.integration.ts b/ts/integrations/flexit/flexit.classes.integration.ts index a66d5ec..ea63179 100644 --- a/ts/integrations/flexit/flexit.classes.integration.ts +++ b/ts/integrations/flexit/flexit.classes.integration.ts @@ -1,24 +1,17 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import { SimpleLocalIntegration } from '../../core/index.js'; +import { FlexitConfigFlow } from './flexit.classes.configflow.js'; +import { createFlexitDiscoveryDescriptor } from './flexit.discovery.js'; +import type { IFlexitConfig } from './flexit.types.js'; +import { flexitDomain, flexitProfile } from './flexit.types.js'; + +export class FlexitIntegration extends SimpleLocalIntegration { + public readonly domain = flexitDomain; + public readonly discoveryDescriptor = createFlexitDiscoveryDescriptor(); + public readonly configFlow = new FlexitConfigFlow(); -export class HomeAssistantFlexitIntegration extends DescriptorOnlyIntegration { constructor() { - super({ - domain: "flexit", - displayName: "Flexit", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/flexit", - "upstreamDomain": "flexit", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [], - "dependencies": [ - "modbus" - ], - "afterDependencies": [], - "codeowners": [] -}, - }); + super(flexitProfile); } } + +export class HomeAssistantFlexitIntegration extends FlexitIntegration {} diff --git a/ts/integrations/flexit/flexit.discovery.ts b/ts/integrations/flexit/flexit.discovery.ts new file mode 100644 index 0000000..cb9ab90 --- /dev/null +++ b/ts/integrations/flexit/flexit.discovery.ts @@ -0,0 +1,4 @@ +import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js'; +import { flexitProfile } from './flexit.types.js'; + +export const createFlexitDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(flexitProfile); diff --git a/ts/integrations/flexit/flexit.mapper.ts b/ts/integrations/flexit/flexit.mapper.ts new file mode 100644 index 0000000..950fd80 --- /dev/null +++ b/ts/integrations/flexit/flexit.mapper.ts @@ -0,0 +1,26 @@ +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js'; +import type { IFlexitConfig } from './flexit.types.js'; +import { flexitProfile } from './flexit.types.js'; + +export class FlexitMapper { + public static toSnapshot(optionsArg: Omit, 'profile'>): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: flexitProfile }); + } + + public static toSnapshotFromRaw(configArg: IFlexitConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot { + return SimpleLocalMapper.toSnapshot({ profile: flexitProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' }); + } + + public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] { + return SimpleLocalMapper.toDevices(flexitProfile, snapshotArg); + } + + public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] { + return SimpleLocalMapper.toEntities(flexitProfile, snapshotArg); + } + + public static slug(valueArg: unknown): string { + return SimpleLocalMapper.slug(valueArg); + } +} diff --git a/ts/integrations/flexit/flexit.types.ts b/ts/integrations/flexit/flexit.types.ts index d0f6f65..ca78079 100644 --- a/ts/integrations/flexit/flexit.types.ts +++ b/ts/integrations/flexit/flexit.types.ts @@ -1,4 +1,94 @@ -export interface IHomeAssistantFlexitConfig { - // TODO: replace with the TypeScript-native config for flexit. - [key: string]: unknown; +import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js'; + +export const flexitDomain = 'flexit'; +export const flexitDefaultName = 'Flexit'; +export const flexitDefaultHub = 'modbus_hub'; + +export type TFlexitRawData = TSimpleLocalRawData; +export interface IFlexitSnapshot extends ISimpleLocalSnapshot {} +export interface IFlexitConfig extends ISimpleLocalConfig { + hub?: string; + slave?: number; } +export interface IHomeAssistantFlexitConfig extends IFlexitConfig {} + +export const flexitProfile: ISimpleLocalIntegrationProfile = { + domain: 'flexit', + displayName: 'Flexit', + manufacturer: 'Flexit', + model: 'CI66 Modbus adapter', + defaultName: flexitDefaultName, + defaultProtocol: 'local', + status: 'read-only-runtime', + platforms: [ + 'climate', + ], + serviceDomains: [ + 'climate', + ], + controlServices: [ + 'set_fan_mode', + 'set_temperature', + ], + discoverySources: [ + 'manual', + 'custom', + ], + discoveryKeywords: [ + 'flexit', + 'ci66', + 'modbus', + 'ventilation', + ], + metadata: { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/flexit', + upstreamDomain: 'flexit', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: [], + dependencies: [ + 'modbus', + ], + afterDependencies: [], + codeowners: [], + configFlow: false, + documentation: 'https://www.home-assistant.io/integrations/flexit', + defaults: { + hub: flexitDefaultHub, + slaveMin: 0, + slaveMax: 32, + fanModes: ['Off', 'Low', 'Medium', 'High'], + temperatureUnit: 'C', + }, + platforms: [ + 'climate', + ], + runtime: { + type: 'read-only-runtime', + services: [ + 'snapshot', + 'status', + 'refresh', + 'set_fan_mode', + 'set_temperature', + ], + platforms: [ + 'climate', + ], + controls: 'delegated only to an injected client.execute or commandExecutor', + }, + localApi: { + implemented: [ + 'manual local setup for Modbus hub/slave metadata, snapshots, raw data, snapshotProvider, and injected native clients', + 'runtime device and climate entity mapping for Flexit CI66 snapshots', + 'safe control delegation to injected client.execute or commandExecutor only', + ], + explicitUnsupported: [ + 'native Modbus register reads and writes in this TypeScript package', + 'claiming target temperature or fan mode write success without an injected client.execute or commandExecutor', + 'Home Assistant YAML platform setup and Modbus hub lookup', + ], + }, + }, +}; diff --git a/ts/integrations/flexit/index.ts b/ts/integrations/flexit/index.ts index facf08f..fdf7d97 100644 --- a/ts/integrations/flexit/index.ts +++ b/ts/integrations/flexit/index.ts @@ -1,2 +1,6 @@ +export * from './flexit.classes.client.js'; +export * from './flexit.classes.configflow.js'; export * from './flexit.classes.integration.js'; +export * from './flexit.discovery.js'; +export * from './flexit.mapper.js'; export * from './flexit.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index a700cf6..c13280c 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -189,69 +189,39 @@ import { HomeAssistantElevenlabsIntegration } from '../elevenlabs/index.js'; import { HomeAssistantEliqonlineIntegration } from '../eliqonline/index.js'; import { HomeAssistantElmaxIntegration } from '../elmax/index.js'; import { HomeAssistantElviaIntegration } from '../elvia/index.js'; -import { HomeAssistantEmoncmsHistoryIntegration } from '../emoncms_history/index.js'; -import { HomeAssistantEmonitorIntegration } from '../emonitor/index.js'; -import { HomeAssistantEmulatedHueIntegration } from '../emulated_hue/index.js'; -import { HomeAssistantEmulatedKasaIntegration } from '../emulated_kasa/index.js'; -import { HomeAssistantEmulatedRokuIntegration } from '../emulated_roku/index.js'; -import { HomeAssistantEnergeniePowerSocketsIntegration } from '../energenie_power_sockets/index.js'; import { HomeAssistantEnergieVanonsIntegration } from '../energie_vanons/index.js'; import { HomeAssistantEnergyIntegration } from '../energy/index.js'; import { HomeAssistantEnergyidIntegration } from '../energyid/index.js'; import { HomeAssistantEnergyzeroIntegration } from '../energyzero/index.js'; -import { HomeAssistantEnigma2Integration } from '../enigma2/index.js'; -import { HomeAssistantEnoceanIntegration } from '../enocean/index.js'; -import { HomeAssistantEnphaseEnvoyIntegration } from '../enphase_envoy/index.js'; import { HomeAssistantEnturPublicTransportIntegration } from '../entur_public_transport/index.js'; import { HomeAssistantEnvironmentCanadaIntegration } from '../environment_canada/index.js'; -import { HomeAssistantEnvisalinkIntegration } from '../envisalink/index.js'; -import { HomeAssistantEphemberIntegration } from '../ephember/index.js'; import { HomeAssistantEpicGamesStoreIntegration } from '../epic_games_store/index.js'; import { HomeAssistantEpionIntegration } from '../epion/index.js'; -import { HomeAssistantEpsonIntegration } from '../epson/index.js'; -import { HomeAssistantEq3btsmartIntegration } from '../eq3btsmart/index.js'; -import { HomeAssistantEsceaIntegration } from '../escea/index.js'; import { HomeAssistantEseraOnewireIntegration } from '../esera_onewire/index.js'; import { HomeAssistantEssentIntegration } from '../essent/index.js'; import { HomeAssistantEtherscanIntegration } from '../etherscan/index.js'; -import { HomeAssistantEufyIntegration } from '../eufy/index.js'; -import { HomeAssistantEufylifeBleIntegration } from '../eufylife_ble/index.js'; -import { HomeAssistantEurotronicCometblueIntegration } from '../eurotronic_cometblue/index.js'; import { HomeAssistantEventIntegration } from '../event/index.js'; import { HomeAssistantEvergyIntegration } from '../evergy/index.js'; -import { HomeAssistantEverlightsIntegration } from '../everlights/index.js'; -import { HomeAssistantEvilGeniusLabsIntegration } from '../evil_genius_labs/index.js'; import { HomeAssistantEvohomeIntegration } from '../evohome/index.js'; import { HomeAssistantEzvizIntegration } from '../ezviz/index.js'; import { HomeAssistantFaaDelaysIntegration } from '../faa_delays/index.js'; import { HomeAssistantFacebookIntegration } from '../facebook/index.js'; -import { HomeAssistantFail2banIntegration } from '../fail2ban/index.js'; -import { HomeAssistantFamilyhubIntegration } from '../familyhub/index.js'; import { HomeAssistantFanIntegration } from '../fan/index.js'; import { HomeAssistantFastdotcomIntegration } from '../fastdotcom/index.js'; import { HomeAssistantFeedreaderIntegration } from '../feedreader/index.js'; import { HomeAssistantFfmpegIntegration } from '../ffmpeg/index.js'; import { HomeAssistantFfmpegMotionIntegration } from '../ffmpeg_motion/index.js'; import { HomeAssistantFfmpegNoiseIntegration } from '../ffmpeg_noise/index.js'; -import { HomeAssistantFibaroIntegration } from '../fibaro/index.js'; import { HomeAssistantFidoIntegration } from '../fido/index.js'; -import { HomeAssistantFileIntegration } from '../file/index.js'; import { HomeAssistantFileUploadIntegration } from '../file_upload/index.js'; -import { HomeAssistantFilesizeIntegration } from '../filesize/index.js'; import { HomeAssistantFilterIntegration } from '../filter/index.js'; -import { HomeAssistantFingIntegration } from '../fing/index.js'; import { HomeAssistantFintsIntegration } from '../fints/index.js'; import { HomeAssistantFireTvIntegration } from '../fire_tv/index.js'; -import { HomeAssistantFireflyIiiIntegration } from '../firefly_iii/index.js'; import { HomeAssistantFireservicerotaIntegration } from '../fireservicerota/index.js'; -import { HomeAssistantFirmataIntegration } from '../firmata/index.js'; import { HomeAssistantFishAudioIntegration } from '../fish_audio/index.js'; import { HomeAssistantFitbitIntegration } from '../fitbit/index.js'; -import { HomeAssistantFivemIntegration } from '../fivem/index.js'; import { HomeAssistantFixerIntegration } from '../fixer/index.js'; -import { HomeAssistantFjaraskupanIntegration } from '../fjaraskupan/index.js'; import { HomeAssistantFleetgoIntegration } from '../fleetgo/index.js'; -import { HomeAssistantFlexitIntegration } from '../flexit/index.js'; import { HomeAssistantFlexitBacnetIntegration } from '../flexit_bacnet/index.js'; import { HomeAssistantFlexomIntegration } from '../flexom/index.js'; import { HomeAssistantFlicIntegration } from '../flic/index.js'; @@ -1423,69 +1393,39 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantElevenlabsIntegrati generatedHomeAssistantPortIntegrations.push(new HomeAssistantEliqonlineIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantElmaxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantElviaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmoncmsHistoryIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmonitorIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmulatedHueIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmulatedKasaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmulatedRokuIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnergeniePowerSocketsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnergieVanonsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnergyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnergyidIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnergyzeroIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnigma2Integration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnoceanIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnphaseEnvoyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnturPublicTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnvironmentCanadaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEnvisalinkIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEphemberIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpicGamesStoreIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpionIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpsonIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEq3btsmartIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsceaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEseraOnewireIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEssentIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEtherscanIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufyIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufylifeBleIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEurotronicCometblueIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEventIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEvergyIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEverlightsIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEvilGeniusLabsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEvohomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEzvizIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFaaDelaysIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFacebookIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFail2banIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFamilyhubIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFanIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFastdotcomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFeedreaderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFfmpegIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFfmpegMotionIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFfmpegNoiseIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFibaroIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFidoIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFileIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFileUploadIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFilesizeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFilterIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFingIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFintsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFireTvIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFireflyIiiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFireservicerotaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFirmataIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFishAudioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFitbitIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFivemIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFixerIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFjaraskupanIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFleetgoIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFlexitIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFlexitBacnetIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFlexomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFlicIntegration()); @@ -2468,7 +2408,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZondergasIntegratio generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1232; +export const generatedHomeAssistantPortCount = 1202; export const handwrittenHomeAssistantPortDomains = [ "acaia", "acer_projector", @@ -2599,7 +2539,37 @@ export const handwrittenHomeAssistantPortDomains = [ "elv", "emby", "emoncms", + "emoncms_history", + "emonitor", + "emulated_hue", + "emulated_kasa", + "emulated_roku", + "energenie_power_sockets", + "enigma2", + "enocean", + "enphase_envoy", + "envisalink", + "ephember", + "epson", + "eq3btsmart", + "escea", "esphome", + "eufy", + "eufylife_ble", + "eurotronic_cometblue", + "everlights", + "evil_genius_labs", + "fail2ban", + "familyhub", + "fibaro", + "file", + "filesize", + "fing", + "firefly_iii", + "firmata", + "fivem", + "fjaraskupan", + "flexit", "forked_daapd", "foscam", "freebox", diff --git a/ts/integrations/index.ts b/ts/integrations/index.ts index b1c0b16..b997df0 100644 --- a/ts/integrations/index.ts +++ b/ts/integrations/index.ts @@ -129,7 +129,37 @@ export * from './elkm1/index.js'; export * from './elv/index.js'; export * from './emby/index.js'; export * from './emoncms/index.js'; +export * from './emoncms_history/index.js'; +export * from './emonitor/index.js'; +export * from './emulated_hue/index.js'; +export * from './emulated_kasa/index.js'; +export * from './emulated_roku/index.js'; +export * from './energenie_power_sockets/index.js'; +export * from './enigma2/index.js'; +export * from './enocean/index.js'; +export * from './enphase_envoy/index.js'; +export * from './envisalink/index.js'; +export * from './ephember/index.js'; +export * from './epson/index.js'; +export * from './eq3btsmart/index.js'; +export * from './escea/index.js'; export * from './esphome/index.js'; +export * from './eufy/index.js'; +export * from './eufylife_ble/index.js'; +export * from './eurotronic_cometblue/index.js'; +export * from './everlights/index.js'; +export * from './evil_genius_labs/index.js'; +export * from './fail2ban/index.js'; +export * from './familyhub/index.js'; +export * from './fibaro/index.js'; +export * from './file/index.js'; +export * from './filesize/index.js'; +export * from './fing/index.js'; +export * from './firefly_iii/index.js'; +export * from './firmata/index.js'; +export * from './fivem/index.js'; +export * from './fjaraskupan/index.js'; +export * from './flexit/index.js'; export * from './forked_daapd/index.js'; export * from './foscam/index.js'; export * from './freebox/index.js';