From 1a7d3c43efaded8cff949efd98f91f24df37a923 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 11 May 2026 23:07:35 +0000 Subject: [PATCH] Add native local platform integrations --- test/flexit_bacnet/test.flexit_bacnet.node.ts | 80 ++ test/flic/test.flic.node.ts | 69 ++ test/flux_led/test.flux_led.node.ts | 85 ++ test/folder/alpha.txt | 1 + test/folder/test.folder.node.ts | 72 ++ test/folder_watcher/event.yaml | 1 + test/folder_watcher/ignore.txt | 1 + .../test.folder_watcher.node.ts | 76 ++ test/fortios/test.fortios.node.ts | 90 +++ test/fritzbox/test.fritzbox.node.ts | 82 ++ test/futurenow/test.futurenow.node.ts | 77 ++ .../test.gardena_bluetooth.node.ts | 82 ++ test/gc100/test.gc100.node.ts | 77 ++ test/generic/test.generic.node.ts | 74 ++ test/geniushub/test.geniushub.node.ts | 86 ++ test/goalzero/test.goalzero.node.ts | 89 +++ test/gogogate2/test.gogogate2.node.ts | 72 ++ test/google_wifi/test.google_wifi.node.ts | 76 ++ test/govee_ble/test.govee_ble.node.ts | 76 ++ .../test.govee_light_local.node.ts | 82 ++ test/gpsd/test.gpsd.node.ts | 77 ++ test/graphite/test.graphite.node.ts | 72 ++ test/gree/test.gree.node.ts | 84 ++ .../test.greeneye_monitor.node.ts | 82 ++ test/greenwave/test.greenwave.node.ts | 80 ++ test/gtfs/test.gtfs.node.ts | 93 +++ test/guardian/test.guardian.node.ts | 87 ++ test/hassio/test.hassio.node.ts | 85 ++ test/hdfury/test.hdfury.node.ts | 86 ++ test/hdmi_cec/test.hdmi_cec.node.ts | 90 +++ test/heatmiser/test.heatmiser.node.ts | 84 ++ test/hegel/test.hegel.node.ts | 76 ++ test/hikvisioncam/test.hikvisioncam.node.ts | 70 ++ .../test.hisense_aehw4a1.node.ts | 78 ++ test/hitron_coda/test.hitron_coda.node.ts | 68 ++ test/hlk_sw16/test.hlk_sw16.node.ts | 72 ++ test/holiday/test.holiday.node.ts | 70 ++ test/homee/test.homee.node.ts | 95 +++ test/homekit/test.homekit.node.ts | 78 ++ test/homevolt/test.homevolt.node.ts | 79 ++ test/homeworks/test.homeworks.node.ts | 79 ++ test/horizon/test.horizon.node.ts | 77 ++ test/hp_ilo/test.hp_ilo.node.ts | 80 ++ .../test.hr_energy_qube.node.ts | 83 ++ test/hue_ble/test.hue_ble.node.ts | 89 +++ .../test.husqvarna_automower_ble.node.ts | 82 ++ test/ialarm/test.ialarm.node.ts | 79 ++ test/iammeter/test.iammeter.node.ts | 82 ++ test/ibeacon/test.ibeacon.node.ts | 85 ++ test/idasen_desk/test.idasen_desk.node.ts | 82 ++ test/idteck_prox/test.idteck_prox.node.ts | 80 ++ test/iglo/test.iglo.node.ts | 79 ++ test/ihc/test.ihc.node.ts | 83 ++ .../test.imeon_inverter.client.node.ts | 106 +++ .../test.imeon_inverter.node.ts | 75 ++ test/immich/test.immich.client.node.ts | 103 +++ test/immich/test.immich.node.ts | 75 ++ test/improv_ble/test.improv_ble.node.ts | 18 + test/incomfort/test.incomfort.node.ts | 163 ++++ test/indevolt/test.indevolt.node.ts | 125 +++ test/inels/test.inels.node.ts | 77 ++ test/influxdb/test.influxdb.node.ts | 178 +++++ test/inkbird/test.inkbird.node.ts | 79 ++ test/insteon/test.insteon.node.ts | 81 ++ test/intellifire/test.intellifire.node.ts | 175 ++++ test/iometer/test.iometer.node.ts | 142 ++++ test/iotawatt/test.iotawatt.node.ts | 133 ++++ test/iperf3/test.iperf3.node.ts | 99 +++ test/iron_os/test.iron_os.node.ts | 99 +++ test/iskra/test.iskra.node.ts | 149 ++++ test/isy994/test.isy994.node.ts | 131 +++ test/itunes/test.itunes.node.ts | 124 +++ test/izone/test.izone.node.ts | 142 ++++ test/jvc_projector/test.jvc_projector.node.ts | 158 ++++ test/kaleidescape/test.kaleidescape.node.ts | 146 ++++ test/kankun/test.kankun.node.ts | 107 +++ test/keba/test.keba.node.ts | 82 ++ .../test.keenetic_ndms2.node.ts | 158 ++++ test/kef/test.kef.node.ts | 140 ++++ test/kegtron/test.kegtron.node.ts | 76 ++ .../test.keyboard_remote.node.ts | 77 ++ test/kiosker/test.kiosker.node.ts | 148 ++++ test/kira/test.kira.node.ts | 96 +++ test/kmtronic/test.kmtronic.node.ts | 103 +++ test/konnected/test.konnected.node.ts | 118 +++ .../test.kostal_plenticore.client.node.ts | 134 ++++ .../test.kostal_plenticore.node.ts | 75 ++ test/kulersky/test.kulersky.node.ts | 75 ++ .../test.kulersky.unsupported.node.ts | 10 + test/kwb/test.kwb.client.node.ts | 50 ++ test/kwb/test.kwb.node.ts | 75 ++ test/lacrosse/test.lacrosse.node.ts | 77 ++ test/lametric/test.lametric.node.ts | 215 +++++ .../test.landisgyr_heat_meter.node.ts | 108 +++ test/lannouncer/test.lannouncer.node.ts | 79 ++ test/lcn/test.lcn.node.ts | 198 +++++ test/ld2410_ble/test.ld2410_ble.node.ts | 82 ++ test/leaone/test.leaone.node.ts | 77 ++ test/led_ble/test.led_ble.node.ts | 78 ++ test/lektrico/test.lektrico.node.ts | 188 +++++ test/lg_netcast/test.lg_netcast.node.ts | 124 +++ test/lg_soundbar/test.lg_soundbar.node.ts | 203 +++++ .../test.libre_hardware_monitor.node.ts | 159 ++++ test/lidarr/test.lidarr.node.ts | 138 ++++ test/lifx/test.lifx.node.ts | 83 ++ test/linksys_smart/test.linksys_smart.node.ts | 160 ++++ test/linux_battery/test.linux_battery.node.ts | 109 +++ test/litejet/test.litejet.node.ts | 77 ++ test/livisi/test.livisi.node.ts | 205 +++++ test/local_calendar/sample.ics | 19 + .../test.local_calendar.node.ts | 96 +++ test/local_file/sample-one.png | 1 + test/local_file/sample-two.jpg | 1 + test/local_file/test.local_file.node.ts | 95 +++ test/local_ip/test.local_ip.node.ts | 100 +++ test/local_todo/sample.ics | 17 + test/local_todo/test.local_todo.node.ts | 101 +++ test/locative/test.locative.node.ts | 78 ++ test/lookin/test.lookin.node.ts | 139 ++++ test/loqed/test.loqed.client_runtime.node.ts | 107 +++ test/loqed/test.loqed.node.ts | 75 ++ test/luci/test.luci.client_runtime.node.ts | 109 +++ test/luci/test.luci.node.ts | 75 ++ .../test.lunatone.client_runtime.node.ts | 140 ++++ test/lunatone/test.lunatone.node.ts | 75 ++ test/lupusec/test.lupusec.node.ts | 136 ++++ test/lutron/test.lutron.node.ts | 185 +++++ test/lutron_caseta/test.lutron_caseta.node.ts | 79 ++ test/lw12wifi/test.lw12wifi.node.ts | 75 ++ .../test.lw12wifi.unsupported.node.ts | 23 + test/manual_mqtt/test.manual_mqtt.node.ts | 75 ++ .../test.manual_mqtt.unsupported.node.ts | 22 + .../test.marytts.client_runtime.node.ts | 90 +++ test/marytts/test.marytts.node.ts | 75 ++ test/maxcube/test.maxcube.node.ts | 186 +++++ test/mcp/test.mcp.node.ts | 156 ++++ test/mcp_server/test.mcp_server.node.ts | 169 ++++ test/mealie/test.mealie.client.node.ts | 108 +++ test/mealie/test.mealie.node.ts | 76 ++ .../medcom_ble/test.medcom_ble.client.node.ts | 35 + test/medcom_ble/test.medcom_ble.node.ts | 76 ++ test/mediaroom/test.mediaroom.client.node.ts | 50 ++ test/mediaroom/test.mediaroom.node.ts | 76 ++ test/melnor/test.melnor.node.ts | 76 ++ test/mfi/test.mfi.node.ts | 148 ++++ test/mill/test.mill.node.ts | 139 ++++ .../test.minecraft_server.node.ts | 148 ++++ test/mjpeg/test.mjpeg.node.ts | 130 +++ test/moat/test.moat.node.ts | 79 ++ test/mobile_app/test.mobile_app.node.ts | 134 ++++ test/mochad/test.mochad.node.ts | 120 +++ .../test.modem_callerid.node.ts | 79 ++ test/modern_forms/test.modern_forms.node.ts | 167 ++++ .../test.moehlenhoff_alpha2.node.ts | 181 +++++ test/monoprice/test.monoprice.node.ts | 80 ++ test/mopeka/test.mopeka.node.ts | 79 ++ test/motion_blinds/test.motion_blinds.node.ts | 79 ++ test/motionmount/test.motionmount.node.ts | 179 +++++ .../test.mqtt_eventstream.node.ts | 111 +++ test/mqtt_json/test.mqtt_json.node.ts | 111 +++ test/mqtt_room/test.mqtt_room.node.ts | 111 +++ .../test.mqtt_statestream.node.ts | 77 ++ .../test.music_assistant.node.ts | 249 ++++++ test/mutesync/test.mutesync.node.ts | 124 +++ test/mycroft/test.mycroft.node.ts | 147 ++++ test/mysensors/test.mysensors.node.ts | 110 +++ test/mystrom/test.mystrom.node.ts | 122 +++ test/nad/test.nad.node.ts | 152 ++++ test/nam/test.nam.node.ts | 143 ++++ test/nasweb/test.nasweb.node.ts | 158 ++++ test/ness_alarm/test.ness_alarm.node.ts | 80 ++ test/netdata/test.netdata.node.ts | 140 ++++ test/netgear/test.netgear.node.ts | 80 ++ test/netgear_lte/test.netgear_lte.node.ts | 140 ++++ test/netio/test.netio.node.ts | 129 +++ test/nfandroidtv/test.nfandroidtv.node.ts | 123 +++ test/nibe_heatpump/test.nibe_heatpump.node.ts | 132 ++++ .../test.niko_home_control.node.ts | 131 +++ test/nmap_tracker/test.nmap_tracker.node.ts | 88 +++ test/nobo_hub/test.nobo_hub.node.ts | 146 ++++ test/nrgkick/test.nrgkick.node.ts | 144 ++++ test/nuki/test.nuki.node.ts | 147 ++++ test/numato/test.numato.node.ts | 79 ++ test/nut/test.nut.node.ts | 154 ++++ test/nx584/test.nx584.node.ts | 136 ++++ test/nzbget/test.nzbget.node.ts | 169 ++++ test/obihai/test.obihai.node.ts | 133 ++++ test/octoprint/test.octoprint.node.ts | 157 ++++ test/oem/test.oem.node.ts | 148 ++++ test/ollama/test.ollama.node.ts | 128 +++ test/ombi/test.ombi.node.ts | 184 +++++ test/onewire/test.onewire.node.ts | 165 ++++ test/onkyo/test.onkyo.node.ts | 201 +++++ test/opendisplay/test.opendisplay.node.ts | 80 ++ test/openevse/test.openevse.node.ts | 184 +++++ test/opengarage/test.opengarage.node.ts | 147 ++++ .../test.openhardwaremonitor.node.ts | 163 ++++ test/openhome/test.openhome.node.ts | 180 +++++ test/opple/test.opple.node.ts | 84 ++ test/oralb/test.oralb.node.ts | 84 ++ test/orvibo/test.orvibo.node.ts | 79 ++ test/osramlightify/test.osramlightify.node.ts | 198 +++++ test/otbr/test.otbr.node.ts | 142 ++++ test/overkiz/test.overkiz.node.ts | 164 ++++ test/overseerr/test.overseerr.node.ts | 153 ++++ test/owntracks/test.owntracks.node.ts | 90 +++ test/p1_monitor/test.p1_monitor.node.ts | 143 ++++ test/palazzetti/test.palazzetti.node.ts | 158 ++++ .../test.panasonic_bluray.node.ts | 130 +++ .../test.panasonic_viera.node.ts | 149 ++++ test/paperless_ngx/test.paperless_ngx.node.ts | 135 ++++ test/peblar/test.peblar.node.ts | 203 +++++ test/pencom/test.pencom.node.ts | 174 ++++ test/pglab/test.pglab.node.ts | 82 ++ test/philips_js/test.philips_js.node.ts | 172 ++++ test/picotts/test.picotts.node.ts | 85 ++ test/pilight/test.pilight.node.ts | 219 ++++++ test/ping/test.ping.node.ts | 87 ++ test/pioneer/test.pioneer.node.ts | 167 ++++ test/pjlink/test.pjlink.node.ts | 191 +++++ test/plugwise/test.plugwise.node.ts | 127 +++ ts/core/classes.simplelocalintegration.ts | 2 +- ts/core/types.ts | 1 + ts/index.ts | 408 +++++++++- .../.generated-by-smarthome-exchange | 1 - .../flexit_bacnet.classes.client.ts | 19 + .../flexit_bacnet.classes.configflow.ts | 9 + .../flexit_bacnet.classes.integration.ts | 43 +- .../flexit_bacnet/flexit_bacnet.discovery.ts | 4 + .../flexit_bacnet/flexit_bacnet.mapper.ts | 227 ++++++ .../flexit_bacnet/flexit_bacnet.types.ts | 119 ++- ts/integrations/flexit_bacnet/index.ts | 4 + .../flic/.generated-by-smarthome-exchange | 1 - ts/integrations/flic/flic.classes.client.ts | 19 + .../flic/flic.classes.configflow.ts | 9 + .../flic/flic.classes.integration.ts | 39 +- ts/integrations/flic/flic.discovery.ts | 4 + ts/integrations/flic/flic.mapper.ts | 116 +++ ts/integrations/flic/flic.types.ts | 96 ++- ts/integrations/flic/index.ts | 4 + .../flux_led/.generated-by-smarthome-exchange | 1 - .../flux_led/flux_led.classes.client.ts | 19 + .../flux_led/flux_led.classes.configflow.ts | 9 + .../flux_led/flux_led.classes.integration.ts | 42 +- .../flux_led/flux_led.discovery.ts | 4 + ts/integrations/flux_led/flux_led.mapper.ts | 190 +++++ ts/integrations/flux_led/flux_led.types.ts | 158 +++- ts/integrations/flux_led/index.ts | 4 + .../folder/.generated-by-smarthome-exchange | 1 - .../folder/folder.classes.client.ts | 83 ++ .../folder/folder.classes.configflow.ts | 9 + .../folder/folder.classes.integration.ts | 37 +- ts/integrations/folder/folder.discovery.ts | 4 + ts/integrations/folder/folder.mapper.ts | 110 +++ ts/integrations/folder/folder.types.ts | 84 +- ts/integrations/folder/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../folder_watcher.classes.client.ts | 75 ++ .../folder_watcher.classes.configflow.ts | 9 + .../folder_watcher.classes.integration.ts | 39 +- .../folder_watcher.discovery.ts | 4 + .../folder_watcher/folder_watcher.mapper.ts | 103 +++ .../folder_watcher/folder_watcher.types.ts | 89 ++- ts/integrations/folder_watcher/index.ts | 4 + .../fortios/.generated-by-smarthome-exchange | 1 - .../fortios/fortios.classes.client.ts | 9 + .../fortios/fortios.classes.configflow.ts | 9 + .../fortios/fortios.classes.integration.ts | 35 +- ts/integrations/fortios/fortios.discovery.ts | 4 + ts/integrations/fortios/fortios.mapper.ts | 26 + ts/integrations/fortios/fortios.types.ts | 95 ++- ts/integrations/fortios/index.ts | 4 + .../fritzbox/.generated-by-smarthome-exchange | 1 - .../fritzbox/fritzbox.classes.client.ts | 9 + .../fritzbox/fritzbox.classes.configflow.ts | 9 + .../fritzbox/fritzbox.classes.integration.ts | 36 +- .../fritzbox/fritzbox.discovery.ts | 4 + ts/integrations/fritzbox/fritzbox.mapper.ts | 26 + ts/integrations/fritzbox/fritzbox.types.ts | 152 +++- ts/integrations/fritzbox/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../futurenow/futurenow.classes.client.ts | 9 + .../futurenow/futurenow.classes.configflow.ts | 9 + .../futurenow.classes.integration.ts | 33 +- .../futurenow/futurenow.discovery.ts | 4 + ts/integrations/futurenow/futurenow.mapper.ts | 26 + ts/integrations/futurenow/futurenow.types.ts | 98 ++- ts/integrations/futurenow/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../gardena_bluetooth.classes.client.ts | 9 + .../gardena_bluetooth.classes.configflow.ts | 9 + .../gardena_bluetooth.classes.integration.ts | 37 +- .../gardena_bluetooth.discovery.ts | 4 + .../gardena_bluetooth.mapper.ts | 26 + .../gardena_bluetooth.types.ts | 147 +++- ts/integrations/gardena_bluetooth/index.ts | 4 + .../gc100/.generated-by-smarthome-exchange | 1 - ts/integrations/gc100/gc100.classes.client.ts | 9 + .../gc100/gc100.classes.configflow.ts | 9 + .../gc100/gc100.classes.integration.ts | 33 +- ts/integrations/gc100/gc100.discovery.ts | 4 + ts/integrations/gc100/gc100.mapper.ts | 26 + ts/integrations/gc100/gc100.types.ts | 99 ++- ts/integrations/gc100/index.ts | 4 + ts/integrations/generated/index.ts | 602 +++++--------- .../generic/.generated-by-smarthome-exchange | 1 - .../generic/generic.classes.client.ts | 18 + .../generic/generic.classes.configflow.ts | 9 + .../generic/generic.classes.integration.ts | 45 +- ts/integrations/generic/generic.discovery.ts | 4 + ts/integrations/generic/generic.mapper.ts | 127 +++ ts/integrations/generic/generic.types.ts | 115 ++- ts/integrations/generic/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../geniushub/geniushub.classes.client.ts | 18 + .../geniushub/geniushub.classes.configflow.ts | 9 + .../geniushub.classes.integration.ts | 40 +- .../geniushub/geniushub.discovery.ts | 4 + ts/integrations/geniushub/geniushub.mapper.ts | 213 +++++ ts/integrations/geniushub/geniushub.types.ts | 129 ++- ts/integrations/geniushub/index.ts | 4 + .../goalzero/.generated-by-smarthome-exchange | 1 - .../goalzero/goalzero.classes.client.ts | 18 + .../goalzero/goalzero.classes.configflow.ts | 9 + .../goalzero/goalzero.classes.integration.ts | 41 +- .../goalzero/goalzero.discovery.ts | 4 + ts/integrations/goalzero/goalzero.mapper.ts | 171 ++++ ts/integrations/goalzero/goalzero.types.ts | 112 ++- ts/integrations/goalzero/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../gogogate2/gogogate2.classes.client.ts | 18 + .../gogogate2/gogogate2.classes.configflow.ts | 9 + .../gogogate2.classes.integration.ts | 41 +- .../gogogate2/gogogate2.discovery.ts | 4 + ts/integrations/gogogate2/gogogate2.mapper.ts | 146 ++++ ts/integrations/gogogate2/gogogate2.types.ts | 115 ++- ts/integrations/gogogate2/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../google_wifi/google_wifi.classes.client.ts | 18 + .../google_wifi.classes.configflow.ts | 9 + .../google_wifi.classes.integration.ts | 37 +- .../google_wifi/google_wifi.discovery.ts | 4 + .../google_wifi/google_wifi.mapper.ts | 102 +++ .../google_wifi/google_wifi.types.ts | 102 ++- ts/integrations/google_wifi/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../govee_ble/govee_ble.classes.client.ts | 21 + .../govee_ble/govee_ble.classes.configflow.ts | 9 + .../govee_ble.classes.integration.ts | 43 +- .../govee_ble/govee_ble.discovery.ts | 4 + ts/integrations/govee_ble/govee_ble.mapper.ts | 221 ++++++ ts/integrations/govee_ble/govee_ble.types.ts | 134 +++- ts/integrations/govee_ble/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../govee_light_local.classes.client.ts | 21 + .../govee_light_local.classes.configflow.ts | 9 + .../govee_light_local.classes.integration.ts | 42 +- .../govee_light_local.discovery.ts | 4 + .../govee_light_local.mapper.ts | 166 ++++ .../govee_light_local.types.ts | 113 ++- ts/integrations/govee_light_local/index.ts | 4 + .../gpsd/.generated-by-smarthome-exchange | 1 - ts/integrations/gpsd/gpsd.classes.client.ts | 21 + .../gpsd/gpsd.classes.configflow.ts | 9 + .../gpsd/gpsd.classes.integration.ts | 41 +- ts/integrations/gpsd/gpsd.discovery.ts | 4 + ts/integrations/gpsd/gpsd.mapper.ts | 133 ++++ ts/integrations/gpsd/gpsd.types.ts | 92 ++- ts/integrations/gpsd/index.ts | 4 + .../graphite/.generated-by-smarthome-exchange | 1 - .../graphite/graphite.classes.client.ts | 21 + .../graphite/graphite.classes.configflow.ts | 9 + .../graphite/graphite.classes.integration.ts | 37 +- .../graphite/graphite.discovery.ts | 4 + ts/integrations/graphite/graphite.mapper.ts | 132 ++++ ts/integrations/graphite/graphite.types.ts | 99 ++- ts/integrations/graphite/index.ts | 4 + .../gree/.generated-by-smarthome-exchange | 1 - ts/integrations/gree/gree.classes.client.ts | 21 + .../gree/gree.classes.configflow.ts | 9 + .../gree/gree.classes.integration.ts | 42 +- ts/integrations/gree/gree.discovery.ts | 4 + ts/integrations/gree/gree.mapper.ts | 199 +++++ ts/integrations/gree/gree.types.ts | 118 ++- ts/integrations/gree/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../greeneye_monitor.classes.client.ts | 9 + .../greeneye_monitor.classes.configflow.ts | 9 + .../greeneye_monitor.classes.integration.ts | 35 +- .../greeneye_monitor.discovery.ts | 4 + .../greeneye_monitor.mapper.ts | 26 + .../greeneye_monitor.types.ts | 96 ++- ts/integrations/greeneye_monitor/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../greenwave/greenwave.classes.client.ts | 9 + .../greenwave/greenwave.classes.configflow.ts | 9 + .../greenwave.classes.integration.ts | 33 +- .../greenwave/greenwave.discovery.ts | 4 + ts/integrations/greenwave/greenwave.mapper.ts | 26 + ts/integrations/greenwave/greenwave.types.ts | 106 ++- ts/integrations/greenwave/index.ts | 4 + .../gtfs/.generated-by-smarthome-exchange | 1 - ts/integrations/gtfs/gtfs.classes.client.ts | 9 + .../gtfs/gtfs.classes.configflow.ts | 9 + .../gtfs/gtfs.classes.integration.ts | 33 +- ts/integrations/gtfs/gtfs.discovery.ts | 4 + ts/integrations/gtfs/gtfs.mapper.ts | 26 + ts/integrations/gtfs/gtfs.types.ts | 96 ++- ts/integrations/gtfs/index.ts | 4 + .../guardian/.generated-by-smarthome-exchange | 1 - .../guardian/guardian.classes.client.ts | 9 + .../guardian/guardian.classes.configflow.ts | 9 + .../guardian/guardian.classes.integration.ts | 35 +- .../guardian/guardian.discovery.ts | 4 + ts/integrations/guardian/guardian.mapper.ts | 26 + ts/integrations/guardian/guardian.types.ts | 140 +++- ts/integrations/guardian/index.ts | 4 + .../hassio/.generated-by-smarthome-exchange | 1 - .../hassio/hassio.classes.client.ts | 9 + .../hassio/hassio.classes.configflow.ts | 9 + .../hassio/hassio.classes.integration.ts | 38 +- ts/integrations/hassio/hassio.discovery.ts | 4 + ts/integrations/hassio/hassio.mapper.ts | 26 + ts/integrations/hassio/hassio.types.ts | 146 +++- ts/integrations/hassio/index.ts | 4 + .../hdfury/.generated-by-smarthome-exchange | 1 - .../hdfury/hdfury.classes.client.ts | 19 + .../hdfury/hdfury.classes.configflow.ts | 9 + .../hdfury/hdfury.classes.integration.ts | 42 +- ts/integrations/hdfury/hdfury.discovery.ts | 4 + ts/integrations/hdfury/hdfury.mapper.ts | 182 +++++ ts/integrations/hdfury/hdfury.types.ts | 113 ++- ts/integrations/hdfury/index.ts | 4 + .../hdmi_cec/.generated-by-smarthome-exchange | 1 - .../hdmi_cec/hdmi_cec.classes.client.ts | 19 + .../hdmi_cec/hdmi_cec.classes.configflow.ts | 9 + .../hdmi_cec/hdmi_cec.classes.integration.ts | 41 +- .../hdmi_cec/hdmi_cec.discovery.ts | 4 + ts/integrations/hdmi_cec/hdmi_cec.mapper.ts | 178 +++++ ts/integrations/hdmi_cec/hdmi_cec.types.ts | 120 ++- ts/integrations/hdmi_cec/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../heatmiser/heatmiser.classes.client.ts | 28 + .../heatmiser/heatmiser.classes.configflow.ts | 9 + .../heatmiser.classes.integration.ts | 41 +- .../heatmiser/heatmiser.discovery.ts | 4 + ts/integrations/heatmiser/heatmiser.mapper.ts | 122 +++ ts/integrations/heatmiser/heatmiser.types.ts | 98 ++- ts/integrations/heatmiser/index.ts | 4 + .../hegel/.generated-by-smarthome-exchange | 1 - ts/integrations/hegel/hegel.classes.client.ts | 28 + .../hegel/hegel.classes.configflow.ts | 9 + .../hegel/hegel.classes.integration.ts | 42 +- ts/integrations/hegel/hegel.discovery.ts | 4 + ts/integrations/hegel/hegel.mapper.ts | 138 ++++ ts/integrations/hegel/hegel.types.ts | 112 ++- ts/integrations/hegel/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../hikvisioncam.classes.client.ts | 19 + .../hikvisioncam.classes.configflow.ts | 9 + .../hikvisioncam.classes.integration.ts | 41 +- .../hikvisioncam/hikvisioncam.discovery.ts | 4 + .../hikvisioncam/hikvisioncam.mapper.ts | 103 +++ .../hikvisioncam/hikvisioncam.types.ts | 99 ++- ts/integrations/hikvisioncam/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../hisense_aehw4a1.classes.client.ts | 23 + .../hisense_aehw4a1.classes.configflow.ts | 9 + .../hisense_aehw4a1.classes.integration.ts | 41 +- .../hisense_aehw4a1.discovery.ts | 4 + .../hisense_aehw4a1/hisense_aehw4a1.mapper.ts | 166 ++++ .../hisense_aehw4a1/hisense_aehw4a1.types.ts | 101 ++- ts/integrations/hisense_aehw4a1/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../hitron_coda/hitron_coda.classes.client.ts | 23 + .../hitron_coda.classes.configflow.ts | 9 + .../hitron_coda.classes.integration.ts | 37 +- .../hitron_coda/hitron_coda.discovery.ts | 4 + .../hitron_coda/hitron_coda.mapper.ts | 118 +++ .../hitron_coda/hitron_coda.types.ts | 90 ++- ts/integrations/hitron_coda/index.ts | 4 + .../hlk_sw16/.generated-by-smarthome-exchange | 1 - .../hlk_sw16/hlk_sw16.classes.client.ts | 23 + .../hlk_sw16/hlk_sw16.classes.configflow.ts | 9 + .../hlk_sw16/hlk_sw16.classes.integration.ts | 41 +- .../hlk_sw16/hlk_sw16.discovery.ts | 4 + ts/integrations/hlk_sw16/hlk_sw16.mapper.ts | 152 ++++ ts/integrations/hlk_sw16/hlk_sw16.types.ts | 102 ++- ts/integrations/hlk_sw16/index.ts | 4 + .../holiday/.generated-by-smarthome-exchange | 1 - .../holiday/holiday.classes.client.ts | 23 + .../holiday/holiday.classes.configflow.ts | 9 + .../holiday/holiday.classes.integration.ts | 42 +- ts/integrations/holiday/holiday.discovery.ts | 4 + ts/integrations/holiday/holiday.mapper.ts | 162 ++++ ts/integrations/holiday/holiday.types.ts | 87 +- ts/integrations/holiday/index.ts | 4 + .../homee/.generated-by-smarthome-exchange | 1 - ts/integrations/homee/homee.classes.client.ts | 23 + .../homee/homee.classes.configflow.ts | 9 + .../homee/homee.classes.integration.ts | 42 +- ts/integrations/homee/homee.discovery.ts | 4 + ts/integrations/homee/homee.mapper.ts | 293 +++++++ ts/integrations/homee/homee.types.ts | 180 ++++- ts/integrations/homee/index.ts | 4 + .../homekit/.generated-by-smarthome-exchange | 1 - .../homekit/homekit.classes.client.ts | 9 + .../homekit/homekit.classes.configflow.ts | 9 + .../homekit/homekit.classes.integration.ts | 45 +- ts/integrations/homekit/homekit.discovery.ts | 4 + ts/integrations/homekit/homekit.mapper.ts | 26 + ts/integrations/homekit/homekit.types.ts | 176 ++++- ts/integrations/homekit/index.ts | 4 + .../homekit_controller.classes.client.ts | 6 +- .../homekit_controller.classes.configflow.ts | 4 +- .../homekit_controller.mapper.ts | 22 +- .../homekit_controller.types.ts | 6 +- .../homevolt/.generated-by-smarthome-exchange | 1 - .../homevolt/homevolt.classes.client.ts | 9 + .../homevolt/homevolt.classes.configflow.ts | 9 + .../homevolt/homevolt.classes.integration.ts | 37 +- .../homevolt/homevolt.discovery.ts | 4 + ts/integrations/homevolt/homevolt.mapper.ts | 26 + ts/integrations/homevolt/homevolt.types.ts | 106 ++- ts/integrations/homevolt/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../homeworks/homeworks.classes.client.ts | 9 + .../homeworks/homeworks.classes.configflow.ts | 9 + .../homeworks.classes.integration.ts | 33 +- .../homeworks/homeworks.discovery.ts | 4 + ts/integrations/homeworks/homeworks.mapper.ts | 26 + ts/integrations/homeworks/homeworks.types.ts | 131 ++- ts/integrations/homeworks/index.ts | 4 + .../horizon/.generated-by-smarthome-exchange | 1 - .../horizon/horizon.classes.client.ts | 9 + .../horizon/horizon.classes.configflow.ts | 9 + .../horizon/horizon.classes.integration.ts | 33 +- ts/integrations/horizon/horizon.discovery.ts | 4 + ts/integrations/horizon/horizon.mapper.ts | 26 + ts/integrations/horizon/horizon.types.ts | 105 ++- ts/integrations/horizon/index.ts | 4 + .../hp_ilo/.generated-by-smarthome-exchange | 1 - .../hp_ilo/hp_ilo.classes.client.ts | 9 + .../hp_ilo/hp_ilo.classes.configflow.ts | 9 + .../hp_ilo/hp_ilo.classes.integration.ts | 33 +- ts/integrations/hp_ilo/hp_ilo.discovery.ts | 4 + ts/integrations/hp_ilo/hp_ilo.mapper.ts | 26 + ts/integrations/hp_ilo/hp_ilo.types.ts | 105 ++- ts/integrations/hp_ilo/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../hr_energy_qube.classes.client.ts | 9 + .../hr_energy_qube.classes.configflow.ts | 9 + .../hr_energy_qube.classes.integration.ts | 36 +- .../hr_energy_qube.discovery.ts | 4 + .../hr_energy_qube/hr_energy_qube.mapper.ts | 26 + .../hr_energy_qube/hr_energy_qube.types.ts | 107 ++- ts/integrations/hr_energy_qube/index.ts | 4 + .../hue_ble/.generated-by-smarthome-exchange | 1 - .../hue_ble/hue_ble.classes.client.ts | 9 + .../hue_ble/hue_ble.classes.configflow.ts | 9 + .../hue_ble/hue_ble.classes.integration.ts | 38 +- ts/integrations/hue_ble/hue_ble.discovery.ts | 4 + ts/integrations/hue_ble/hue_ble.mapper.ts | 26 + ts/integrations/hue_ble/hue_ble.types.ts | 111 ++- ts/integrations/hue_ble/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../husqvarna_automower_ble.classes.client.ts | 9 + ...qvarna_automower_ble.classes.configflow.ts | 9 + ...varna_automower_ble.classes.integration.ts | 38 +- .../husqvarna_automower_ble.discovery.ts | 4 + .../husqvarna_automower_ble.mapper.ts | 26 + .../husqvarna_automower_ble.types.ts | 112 ++- .../husqvarna_automower_ble/index.ts | 4 + .../ialarm/.generated-by-smarthome-exchange | 1 - .../ialarm/ialarm.classes.client.ts | 9 + .../ialarm/ialarm.classes.configflow.ts | 9 + .../ialarm/ialarm.classes.integration.ts | 35 +- ts/integrations/ialarm/ialarm.discovery.ts | 4 + ts/integrations/ialarm/ialarm.mapper.ts | 26 + ts/integrations/ialarm/ialarm.types.ts | 106 ++- ts/integrations/ialarm/index.ts | 4 + .../iammeter/.generated-by-smarthome-exchange | 1 - .../iammeter/iammeter.classes.client.ts | 9 + .../iammeter/iammeter.classes.configflow.ts | 9 + .../iammeter/iammeter.classes.integration.ts | 35 +- .../iammeter/iammeter.discovery.ts | 4 + ts/integrations/iammeter/iammeter.mapper.ts | 26 + ts/integrations/iammeter/iammeter.types.ts | 105 ++- ts/integrations/iammeter/index.ts | 4 + .../ibeacon/.generated-by-smarthome-exchange | 1 - .../ibeacon/ibeacon.classes.client.ts | 9 + .../ibeacon/ibeacon.classes.configflow.ts | 9 + .../ibeacon/ibeacon.classes.integration.ts | 35 +- ts/integrations/ibeacon/ibeacon.discovery.ts | 4 + ts/integrations/ibeacon/ibeacon.mapper.ts | 26 + ts/integrations/ibeacon/ibeacon.types.ts | 99 ++- ts/integrations/ibeacon/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../idasen_desk/idasen_desk.classes.client.ts | 9 + .../idasen_desk.classes.configflow.ts | 9 + .../idasen_desk.classes.integration.ts | 38 +- .../idasen_desk/idasen_desk.discovery.ts | 4 + .../idasen_desk/idasen_desk.mapper.ts | 26 + .../idasen_desk/idasen_desk.types.ts | 115 ++- ts/integrations/idasen_desk/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../idteck_prox/idteck_prox.classes.client.ts | 9 + .../idteck_prox.classes.configflow.ts | 9 + .../idteck_prox.classes.integration.ts | 33 +- .../idteck_prox/idteck_prox.discovery.ts | 4 + .../idteck_prox/idteck_prox.mapper.ts | 26 + .../idteck_prox/idteck_prox.types.ts | 81 +- ts/integrations/idteck_prox/index.ts | 4 + .../iglo/.generated-by-smarthome-exchange | 1 - ts/integrations/iglo/iglo.classes.client.ts | 9 + .../iglo/iglo.classes.configflow.ts | 9 + .../iglo/iglo.classes.integration.ts | 33 +- ts/integrations/iglo/iglo.discovery.ts | 4 + ts/integrations/iglo/iglo.mapper.ts | 26 + ts/integrations/iglo/iglo.types.ts | 98 ++- ts/integrations/iglo/index.ts | 4 + .../ihc/.generated-by-smarthome-exchange | 1 - ts/integrations/ihc/ihc.classes.client.ts | 9 + ts/integrations/ihc/ihc.classes.configflow.ts | 9 + .../ihc/ihc.classes.integration.ts | 34 +- ts/integrations/ihc/ihc.discovery.ts | 4 + ts/integrations/ihc/ihc.mapper.ts | 26 + ts/integrations/ihc/ihc.types.ts | 119 ++- ts/integrations/ihc/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../imeon_inverter.classes.client.ts | 565 +++++++++++++ .../imeon_inverter.classes.configflow.ts | 9 + .../imeon_inverter.classes.integration.ts | 40 +- .../imeon_inverter.discovery.ts | 4 + .../imeon_inverter/imeon_inverter.mapper.ts | 26 + .../imeon_inverter/imeon_inverter.types.ts | 133 +++- ts/integrations/imeon_inverter/index.ts | 4 + .../immich/.generated-by-smarthome-exchange | 1 - .../immich/immich.classes.client.ts | 377 +++++++++ .../immich/immich.classes.configflow.ts | 9 + .../immich/immich.classes.integration.ts | 42 +- ts/integrations/immich/immich.discovery.ts | 4 + ts/integrations/immich/immich.mapper.ts | 26 + ts/integrations/immich/immich.types.ts | 141 +++- ts/integrations/immich/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../improv_ble/improv_ble.classes.client.ts | 9 + .../improv_ble.classes.configflow.ts | 9 + .../improv_ble.classes.integration.ts | 35 +- .../improv_ble/improv_ble.discovery.ts | 4 + .../improv_ble/improv_ble.mapper.ts | 26 + .../improv_ble/improv_ble.types.ts | 84 +- ts/integrations/improv_ble/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../incomfort/incomfort.classes.client.ts | 446 +++++++++++ .../incomfort/incomfort.classes.configflow.ts | 9 + .../incomfort.classes.integration.ts | 81 +- .../incomfort/incomfort.discovery.ts | 4 + ts/integrations/incomfort/incomfort.mapper.ts | 26 + ts/integrations/incomfort/incomfort.types.ts | 100 ++- ts/integrations/incomfort/index.ts | 4 + .../indevolt/.generated-by-smarthome-exchange | 1 - .../indevolt/indevolt.classes.client.ts | 744 ++++++++++++++++++ .../indevolt/indevolt.classes.configflow.ts | 9 + .../indevolt/indevolt.classes.integration.ts | 85 +- .../indevolt/indevolt.discovery.ts | 4 + ts/integrations/indevolt/indevolt.mapper.ts | 26 + ts/integrations/indevolt/indevolt.types.ts | 114 ++- ts/integrations/indevolt/index.ts | 4 + ts/integrations/index.ts | 200 +++++ .../inels/.generated-by-smarthome-exchange | 1 - ts/integrations/inels/index.ts | 4 + ts/integrations/inels/inels.classes.client.ts | 9 + .../inels/inels.classes.configflow.ts | 9 + .../inels/inels.classes.integration.ts | 36 +- ts/integrations/inels/inels.discovery.ts | 4 + ts/integrations/inels/inels.mapper.ts | 26 + ts/integrations/inels/inels.types.ts | 93 ++- .../influxdb/.generated-by-smarthome-exchange | 1 - ts/integrations/influxdb/index.ts | 4 + .../influxdb/influxdb.classes.client.ts | 575 ++++++++++++++ .../influxdb/influxdb.classes.configflow.ts | 9 + .../influxdb/influxdb.classes.integration.ts | 44 +- .../influxdb/influxdb.discovery.ts | 4 + ts/integrations/influxdb/influxdb.mapper.ts | 26 + ts/integrations/influxdb/influxdb.types.ts | 192 ++++- .../inkbird/.generated-by-smarthome-exchange | 1 - ts/integrations/inkbird/index.ts | 4 + .../inkbird/inkbird.classes.client.ts | 9 + .../inkbird/inkbird.classes.configflow.ts | 9 + .../inkbird/inkbird.classes.integration.ts | 37 +- ts/integrations/inkbird/inkbird.discovery.ts | 4 + ts/integrations/inkbird/inkbird.mapper.ts | 26 + ts/integrations/inkbird/inkbird.types.ts | 83 +- .../insteon/.generated-by-smarthome-exchange | 1 - ts/integrations/insteon/index.ts | 4 + .../insteon/insteon.classes.client.ts | 9 + .../insteon/insteon.classes.configflow.ts | 9 + .../insteon/insteon.classes.integration.ts | 43 +- ts/integrations/insteon/insteon.discovery.ts | 4 + ts/integrations/insteon/insteon.mapper.ts | 26 + ts/integrations/insteon/insteon.types.ts | 149 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/intellifire/index.ts | 4 + .../intellifire/intellifire.classes.client.ts | 319 ++++++++ .../intellifire.classes.configflow.ts | 9 + .../intellifire.classes.integration.ts | 39 +- .../intellifire/intellifire.discovery.ts | 4 + .../intellifire/intellifire.mapper.ts | 222 ++++++ .../intellifire/intellifire.types.ts | 155 +++- .../iometer/.generated-by-smarthome-exchange | 1 - ts/integrations/iometer/index.ts | 4 + .../iometer/iometer.classes.client.ts | 161 ++++ .../iometer/iometer.classes.configflow.ts | 9 + .../iometer/iometer.classes.integration.ts | 40 +- ts/integrations/iometer/iometer.discovery.ts | 4 + ts/integrations/iometer/iometer.mapper.ts | 180 +++++ ts/integrations/iometer/iometer.types.ts | 128 ++- .../iotawatt/.generated-by-smarthome-exchange | 1 - ts/integrations/iotawatt/index.ts | 4 + .../iotawatt/iotawatt.classes.client.ts | 349 ++++++++ .../iotawatt/iotawatt.classes.configflow.ts | 9 + .../iotawatt/iotawatt.classes.integration.ts | 40 +- .../iotawatt/iotawatt.discovery.ts | 4 + ts/integrations/iotawatt/iotawatt.mapper.ts | 155 ++++ ts/integrations/iotawatt/iotawatt.types.ts | 129 ++- .../iperf3/.generated-by-smarthome-exchange | 1 - ts/integrations/iperf3/index.ts | 4 + .../iperf3/iperf3.classes.client.ts | 25 + .../iperf3/iperf3.classes.configflow.ts | 9 + .../iperf3/iperf3.classes.integration.ts | 41 +- ts/integrations/iperf3/iperf3.discovery.ts | 4 + ts/integrations/iperf3/iperf3.mapper.ts | 26 + ts/integrations/iperf3/iperf3.types.ts | 86 +- .../iron_os/.generated-by-smarthome-exchange | 1 - ts/integrations/iron_os/index.ts | 4 + .../iron_os/iron_os.classes.client.ts | 25 + .../iron_os/iron_os.classes.configflow.ts | 9 + .../iron_os/iron_os.classes.integration.ts | 44 +- ts/integrations/iron_os/iron_os.discovery.ts | 4 + ts/integrations/iron_os/iron_os.mapper.ts | 26 + ts/integrations/iron_os/iron_os.types.ts | 118 ++- .../iskra/.generated-by-smarthome-exchange | 1 - ts/integrations/iskra/index.ts | 4 + ts/integrations/iskra/iskra.classes.client.ts | 212 +++++ .../iskra/iskra.classes.configflow.ts | 9 + .../iskra/iskra.classes.integration.ts | 41 +- ts/integrations/iskra/iskra.discovery.ts | 4 + ts/integrations/iskra/iskra.mapper.ts | 243 ++++++ ts/integrations/iskra/iskra.types.ts | 108 ++- .../isy994/.generated-by-smarthome-exchange | 1 - ts/integrations/isy994/index.ts | 4 + .../isy994/isy994.classes.client.ts | 499 ++++++++++++ .../isy994/isy994.classes.configflow.ts | 9 + .../isy994/isy994.classes.integration.ts | 42 +- ts/integrations/isy994/isy994.discovery.ts | 4 + ts/integrations/isy994/isy994.mapper.ts | 26 + ts/integrations/isy994/isy994.types.ts | 153 +++- .../itunes/.generated-by-smarthome-exchange | 1 - ts/integrations/itunes/index.ts | 4 + .../itunes/itunes.classes.client.ts | 276 +++++++ .../itunes/itunes.classes.configflow.ts | 9 + .../itunes/itunes.classes.integration.ts | 37 +- ts/integrations/itunes/itunes.discovery.ts | 4 + ts/integrations/itunes/itunes.mapper.ts | 26 + ts/integrations/itunes/itunes.types.ts | 90 ++- .../izone/.generated-by-smarthome-exchange | 1 - ts/integrations/izone/index.ts | 4 + ts/integrations/izone/izone.classes.client.ts | 409 ++++++++++ .../izone/izone.classes.configflow.ts | 9 + .../izone/izone.classes.integration.ts | 41 +- ts/integrations/izone/izone.discovery.ts | 4 + ts/integrations/izone/izone.mapper.ts | 26 + ts/integrations/izone/izone.types.ts | 96 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/jvc_projector/index.ts | 4 + .../jvc_projector.classes.client.ts | 637 +++++++++++++++ .../jvc_projector.classes.configflow.ts | 9 + .../jvc_projector.classes.integration.ts | 41 +- .../jvc_projector/jvc_projector.discovery.ts | 4 + .../jvc_projector/jvc_projector.mapper.ts | 26 + .../jvc_projector/jvc_projector.types.ts | 111 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/kaleidescape/index.ts | 4 + .../kaleidescape.classes.client.ts | 601 ++++++++++++++ .../kaleidescape.classes.configflow.ts | 9 + .../kaleidescape.classes.integration.ts | 41 +- .../kaleidescape/kaleidescape.discovery.ts | 4 + .../kaleidescape/kaleidescape.mapper.ts | 26 + .../kaleidescape/kaleidescape.types.ts | 108 ++- .../kankun/.generated-by-smarthome-exchange | 1 - ts/integrations/kankun/index.ts | 4 + .../kankun/kankun.classes.client.ts | 212 +++++ .../kankun/kankun.classes.configflow.ts | 9 + .../kankun/kankun.classes.integration.ts | 37 +- ts/integrations/kankun/kankun.discovery.ts | 4 + ts/integrations/kankun/kankun.mapper.ts | 26 + ts/integrations/kankun/kankun.types.ts | 85 +- .../keba/.generated-by-smarthome-exchange | 1 - ts/integrations/keba/index.ts | 4 + ts/integrations/keba/keba.classes.client.ts | 9 + .../keba/keba.classes.configflow.ts | 9 + .../keba/keba.classes.integration.ts | 35 +- ts/integrations/keba/keba.discovery.ts | 4 + ts/integrations/keba/keba.mapper.ts | 26 + ts/integrations/keba/keba.types.ts | 115 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/keenetic_ndms2/index.ts | 4 + .../keenetic_ndms2.classes.client.ts | 656 +++++++++++++++ .../keenetic_ndms2.classes.configflow.ts | 9 + .../keenetic_ndms2.classes.integration.ts | 41 +- .../keenetic_ndms2.discovery.ts | 4 + .../keenetic_ndms2/keenetic_ndms2.mapper.ts | 26 + .../keenetic_ndms2/keenetic_ndms2.types.ts | 97 ++- .../kef/.generated-by-smarthome-exchange | 1 - ts/integrations/kef/index.ts | 4 + ts/integrations/kef/kef.classes.client.ts | 578 ++++++++++++++ ts/integrations/kef/kef.classes.configflow.ts | 9 + .../kef/kef.classes.integration.ts | 42 +- ts/integrations/kef/kef.discovery.ts | 4 + ts/integrations/kef/kef.mapper.ts | 26 + ts/integrations/kef/kef.types.ts | 126 ++- .../kegtron/.generated-by-smarthome-exchange | 1 - ts/integrations/kegtron/index.ts | 4 + .../kegtron/kegtron.classes.client.ts | 9 + .../kegtron/kegtron.classes.configflow.ts | 9 + .../kegtron/kegtron.classes.integration.ts | 35 +- ts/integrations/kegtron/kegtron.discovery.ts | 4 + ts/integrations/kegtron/kegtron.mapper.ts | 26 + ts/integrations/kegtron/kegtron.types.ts | 80 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/keyboard_remote/index.ts | 4 + .../keyboard_remote.classes.client.ts | 9 + .../keyboard_remote.classes.configflow.ts | 9 + .../keyboard_remote.classes.integration.ts | 36 +- .../keyboard_remote.discovery.ts | 4 + .../keyboard_remote/keyboard_remote.mapper.ts | 26 + .../keyboard_remote/keyboard_remote.types.ts | 86 +- .../kiosker/.generated-by-smarthome-exchange | 1 - ts/integrations/kiosker/index.ts | 4 + .../kiosker/kiosker.classes.client.ts | 267 +++++++ .../kiosker/kiosker.classes.configflow.ts | 9 + .../kiosker/kiosker.classes.integration.ts | 40 +- ts/integrations/kiosker/kiosker.discovery.ts | 4 + ts/integrations/kiosker/kiosker.mapper.ts | 194 +++++ ts/integrations/kiosker/kiosker.types.ts | 131 ++- .../kira/.generated-by-smarthome-exchange | 1 - ts/integrations/kira/index.ts | 4 + ts/integrations/kira/kira.classes.client.ts | 204 +++++ .../kira/kira.classes.configflow.ts | 9 + .../kira/kira.classes.integration.ts | 37 +- ts/integrations/kira/kira.discovery.ts | 4 + ts/integrations/kira/kira.mapper.ts | 26 + ts/integrations/kira/kira.types.ts | 92 ++- .../kmtronic/.generated-by-smarthome-exchange | 1 - ts/integrations/kmtronic/index.ts | 4 + .../kmtronic/kmtronic.classes.client.ts | 285 +++++++ .../kmtronic/kmtronic.classes.configflow.ts | 9 + .../kmtronic/kmtronic.classes.integration.ts | 39 +- .../kmtronic/kmtronic.discovery.ts | 4 + ts/integrations/kmtronic/kmtronic.mapper.ts | 26 + ts/integrations/kmtronic/kmtronic.types.ts | 99 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/konnected/index.ts | 4 + .../konnected/konnected.classes.client.ts | 353 +++++++++ .../konnected/konnected.classes.configflow.ts | 9 + .../konnected.classes.integration.ts | 41 +- .../konnected/konnected.discovery.ts | 4 + ts/integrations/konnected/konnected.mapper.ts | 26 + ts/integrations/konnected/konnected.types.ts | 118 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/kostal_plenticore/index.ts | 4 + .../kostal_plenticore.classes.client.ts | 539 +++++++++++++ .../kostal_plenticore.classes.configflow.ts | 9 + .../kostal_plenticore.classes.integration.ts | 39 +- .../kostal_plenticore.discovery.ts | 4 + .../kostal_plenticore.mapper.ts | 334 ++++++++ .../kostal_plenticore.types.ts | 138 +++- .../kulersky/.generated-by-smarthome-exchange | 1 - ts/integrations/kulersky/index.ts | 4 + .../kulersky/kulersky.classes.client.ts | 9 + .../kulersky/kulersky.classes.configflow.ts | 9 + .../kulersky/kulersky.classes.integration.ts | 35 +- .../kulersky/kulersky.discovery.ts | 4 + ts/integrations/kulersky/kulersky.mapper.ts | 26 + ts/integrations/kulersky/kulersky.types.ts | 97 ++- .../kwb/.generated-by-smarthome-exchange | 1 - ts/integrations/kwb/index.ts | 4 + ts/integrations/kwb/kwb.classes.client.ts | 401 ++++++++++ ts/integrations/kwb/kwb.classes.configflow.ts | 9 + .../kwb/kwb.classes.integration.ts | 37 +- ts/integrations/kwb/kwb.discovery.ts | 4 + ts/integrations/kwb/kwb.mapper.ts | 94 +++ ts/integrations/kwb/kwb.types.ts | 115 ++- .../lacrosse/.generated-by-smarthome-exchange | 1 - ts/integrations/lacrosse/index.ts | 4 + .../lacrosse/lacrosse.classes.client.ts | 9 + .../lacrosse/lacrosse.classes.configflow.ts | 9 + .../lacrosse/lacrosse.classes.integration.ts | 31 +- .../lacrosse/lacrosse.discovery.ts | 4 + ts/integrations/lacrosse/lacrosse.mapper.ts | 26 + ts/integrations/lacrosse/lacrosse.types.ts | 80 +- .../lametric/.generated-by-smarthome-exchange | 1 - ts/integrations/lametric/index.ts | 4 + .../lametric/lametric.classes.client.ts | 412 ++++++++++ .../lametric/lametric.classes.configflow.ts | 112 +++ .../lametric/lametric.classes.integration.ts | 43 +- .../lametric/lametric.discovery.ts | 4 + ts/integrations/lametric/lametric.mapper.ts | 318 ++++++++ ts/integrations/lametric/lametric.types.ts | 202 ++++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/landisgyr_heat_meter/index.ts | 4 + .../landisgyr_heat_meter.classes.client.ts | 97 +++ ...landisgyr_heat_meter.classes.configflow.ts | 9 + ...andisgyr_heat_meter.classes.integration.ts | 41 +- .../landisgyr_heat_meter.discovery.ts | 4 + .../landisgyr_heat_meter.mapper.ts | 255 ++++++ .../landisgyr_heat_meter.types.ts | 89 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/lannouncer/index.ts | 4 + .../lannouncer/lannouncer.classes.client.ts | 9 + .../lannouncer.classes.configflow.ts | 9 + .../lannouncer.classes.integration.ts | 31 +- .../lannouncer/lannouncer.discovery.ts | 4 + .../lannouncer/lannouncer.mapper.ts | 26 + .../lannouncer/lannouncer.types.ts | 75 +- .../lcn/.generated-by-smarthome-exchange | 1 - ts/integrations/lcn/index.ts | 4 + ts/integrations/lcn/lcn.classes.client.ts | 371 +++++++++ ts/integrations/lcn/lcn.classes.configflow.ts | 9 + .../lcn/lcn.classes.integration.ts | 48 +- ts/integrations/lcn/lcn.discovery.ts | 4 + ts/integrations/lcn/lcn.mapper.ts | 26 + ts/integrations/lcn/lcn.types.ts | 170 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/ld2410_ble/index.ts | 4 + .../ld2410_ble/ld2410_ble.classes.client.ts | 9 + .../ld2410_ble.classes.configflow.ts | 9 + .../ld2410_ble.classes.integration.ts | 38 +- .../ld2410_ble/ld2410_ble.discovery.ts | 4 + .../ld2410_ble/ld2410_ble.mapper.ts | 26 + .../ld2410_ble/ld2410_ble.types.ts | 88 ++- .../leaone/.generated-by-smarthome-exchange | 1 - ts/integrations/leaone/index.ts | 4 + .../leaone/leaone.classes.client.ts | 9 + .../leaone/leaone.classes.configflow.ts | 9 + .../leaone/leaone.classes.integration.ts | 35 +- ts/integrations/leaone/leaone.discovery.ts | 4 + ts/integrations/leaone/leaone.mapper.ts | 26 + ts/integrations/leaone/leaone.types.ts | 81 +- .../led_ble/.generated-by-smarthome-exchange | 1 - ts/integrations/led_ble/index.ts | 4 + .../led_ble/led_ble.classes.client.ts | 9 + .../led_ble/led_ble.classes.configflow.ts | 9 + .../led_ble/led_ble.classes.integration.ts | 36 +- ts/integrations/led_ble/led_ble.discovery.ts | 4 + ts/integrations/led_ble/led_ble.mapper.ts | 26 + ts/integrations/led_ble/led_ble.types.ts | 94 ++- .../lektrico/.generated-by-smarthome-exchange | 1 - ts/integrations/lektrico/index.ts | 4 + .../lektrico/lektrico.classes.client.ts | 577 ++++++++++++++ .../lektrico/lektrico.classes.configflow.ts | 9 + .../lektrico/lektrico.classes.integration.ts | 39 +- .../lektrico/lektrico.discovery.ts | 4 + ts/integrations/lektrico/lektrico.mapper.ts | 26 + ts/integrations/lektrico/lektrico.types.ts | 137 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/lg_netcast/index.ts | 4 + .../lg_netcast/lg_netcast.classes.client.ts | 452 +++++++++++ .../lg_netcast.classes.configflow.ts | 9 + .../lg_netcast.classes.integration.ts | 44 +- .../lg_netcast/lg_netcast.discovery.ts | 4 + .../lg_netcast/lg_netcast.mapper.ts | 26 + .../lg_netcast/lg_netcast.types.ts | 112 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/lg_soundbar/index.ts | 4 + .../lg_soundbar/lg_soundbar.classes.client.ts | 430 ++++++++++ .../lg_soundbar.classes.configflow.ts | 9 + .../lg_soundbar.classes.integration.ts | 39 +- .../lg_soundbar/lg_soundbar.discovery.ts | 4 + .../lg_soundbar/lg_soundbar.mapper.ts | 26 + .../lg_soundbar/lg_soundbar.types.ts | 95 ++- .../.generated-by-smarthome-exchange | 1 - .../libre_hardware_monitor/index.ts | 4 + .../libre_hardware_monitor.classes.client.ts | 407 ++++++++++ ...bre_hardware_monitor.classes.configflow.ts | 9 + ...re_hardware_monitor.classes.integration.ts | 42 +- .../libre_hardware_monitor.discovery.ts | 4 + .../libre_hardware_monitor.mapper.ts | 26 + .../libre_hardware_monitor.types.ts | 84 +- .../lidarr/.generated-by-smarthome-exchange | 1 - ts/integrations/lidarr/index.ts | 4 + .../lidarr/lidarr.classes.client.ts | 146 ++++ .../lidarr/lidarr.classes.configflow.ts | 9 + .../lidarr/lidarr.classes.integration.ts | 41 +- ts/integrations/lidarr/lidarr.discovery.ts | 4 + ts/integrations/lidarr/lidarr.mapper.ts | 194 +++++ ts/integrations/lidarr/lidarr.types.ts | 132 +++- .../lifx/.generated-by-smarthome-exchange | 1 - ts/integrations/lifx/index.ts | 4 + ts/integrations/lifx/lifx.classes.client.ts | 9 + .../lifx/lifx.classes.configflow.ts | 9 + .../lifx/lifx.classes.integration.ts | 39 +- ts/integrations/lifx/lifx.discovery.ts | 4 + ts/integrations/lifx/lifx.mapper.ts | 26 + ts/integrations/lifx/lifx.types.ts | 134 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/linksys_smart/index.ts | 4 + .../linksys_smart.classes.client.ts | 119 +++ .../linksys_smart.classes.configflow.ts | 9 + .../linksys_smart.classes.integration.ts | 37 +- .../linksys_smart/linksys_smart.discovery.ts | 4 + .../linksys_smart/linksys_smart.mapper.ts | 130 +++ .../linksys_smart/linksys_smart.types.ts | 117 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/linux_battery/index.ts | 4 + .../linux_battery.classes.client.ts | 206 +++++ .../linux_battery.classes.configflow.ts | 9 + .../linux_battery.classes.integration.ts | 39 +- .../linux_battery/linux_battery.discovery.ts | 4 + .../linux_battery/linux_battery.mapper.ts | 26 + .../linux_battery/linux_battery.types.ts | 86 +- .../litejet/.generated-by-smarthome-exchange | 1 - ts/integrations/litejet/index.ts | 4 + .../litejet/litejet.classes.client.ts | 9 + .../litejet/litejet.classes.configflow.ts | 9 + .../litejet/litejet.classes.integration.ts | 33 +- ts/integrations/litejet/litejet.discovery.ts | 4 + ts/integrations/litejet/litejet.mapper.ts | 26 + ts/integrations/litejet/litejet.types.ts | 97 ++- .../livisi/.generated-by-smarthome-exchange | 1 - ts/integrations/livisi/index.ts | 4 + .../livisi/livisi.classes.client.ts | 621 +++++++++++++++ .../livisi/livisi.classes.configflow.ts | 9 + .../livisi/livisi.classes.integration.ts | 40 +- ts/integrations/livisi/livisi.discovery.ts | 4 + ts/integrations/livisi/livisi.mapper.ts | 26 + ts/integrations/livisi/livisi.types.ts | 106 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/local_calendar/index.ts | 4 + .../local_calendar.classes.client.ts | 166 ++++ .../local_calendar.classes.configflow.ts | 9 + .../local_calendar.classes.integration.ts | 40 +- .../local_calendar.discovery.ts | 4 + .../local_calendar/local_calendar.mapper.ts | 118 +++ .../local_calendar/local_calendar.types.ts | 101 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/local_file/index.ts | 4 + .../local_file/local_file.classes.client.ts | 105 +++ .../local_file.classes.configflow.ts | 9 + .../local_file.classes.integration.ts | 34 +- .../local_file/local_file.discovery.ts | 4 + .../local_file/local_file.mapper.ts | 95 +++ .../local_file/local_file.types.ts | 76 +- .../local_ip/.generated-by-smarthome-exchange | 1 - ts/integrations/local_ip/index.ts | 4 + .../local_ip/local_ip.classes.client.ts | 104 +++ .../local_ip/local_ip.classes.configflow.ts | 9 + .../local_ip/local_ip.classes.integration.ts | 38 +- .../local_ip/local_ip.discovery.ts | 4 + ts/integrations/local_ip/local_ip.mapper.ts | 92 +++ ts/integrations/local_ip/local_ip.types.ts | 83 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/local_todo/index.ts | 4 + .../local_todo/local_todo.classes.client.ts | 244 ++++++ .../local_todo.classes.configflow.ts | 9 + .../local_todo.classes.integration.ts | 40 +- .../local_todo/local_todo.discovery.ts | 4 + .../local_todo/local_todo.mapper.ts | 26 + .../local_todo/local_todo.types.ts | 85 +- .../locative/.generated-by-smarthome-exchange | 1 - ts/integrations/locative/index.ts | 4 + .../locative/locative.classes.client.ts | 9 + .../locative/locative.classes.configflow.ts | 9 + .../locative/locative.classes.integration.ts | 32 +- .../locative/locative.discovery.ts | 4 + ts/integrations/locative/locative.mapper.ts | 26 + ts/integrations/locative/locative.types.ts | 76 +- .../lookin/.generated-by-smarthome-exchange | 1 - ts/integrations/lookin/index.ts | 4 + .../lookin/lookin.classes.client.ts | 505 ++++++++++++ .../lookin/lookin.classes.configflow.ts | 9 + .../lookin/lookin.classes.integration.ts | 42 +- ts/integrations/lookin/lookin.discovery.ts | 4 + ts/integrations/lookin/lookin.mapper.ts | 26 + ts/integrations/lookin/lookin.types.ts | 127 ++- .../loqed/.generated-by-smarthome-exchange | 1 - ts/integrations/loqed/index.ts | 4 + ts/integrations/loqed/loqed.classes.client.ts | 169 ++++ .../loqed/loqed.classes.configflow.ts | 9 + .../loqed/loqed.classes.integration.ts | 43 +- ts/integrations/loqed/loqed.discovery.ts | 4 + ts/integrations/loqed/loqed.mapper.ts | 204 +++++ ts/integrations/loqed/loqed.types.ts | 134 +++- .../luci/.generated-by-smarthome-exchange | 1 - ts/integrations/luci/index.ts | 4 + ts/integrations/luci/luci.classes.client.ts | 234 ++++++ .../luci/luci.classes.configflow.ts | 9 + .../luci/luci.classes.integration.ts | 39 +- ts/integrations/luci/luci.discovery.ts | 4 + ts/integrations/luci/luci.mapper.ts | 155 ++++ ts/integrations/luci/luci.types.ts | 115 ++- .../lunatone/.generated-by-smarthome-exchange | 1 - ts/integrations/lunatone/index.ts | 4 + .../lunatone/lunatone.classes.client.ts | 190 +++++ .../lunatone/lunatone.classes.configflow.ts | 9 + .../lunatone/lunatone.classes.integration.ts | 40 +- .../lunatone/lunatone.discovery.ts | 4 + ts/integrations/lunatone/lunatone.mapper.ts | 290 +++++++ ts/integrations/lunatone/lunatone.types.ts | 142 +++- .../lupusec/.generated-by-smarthome-exchange | 1 - ts/integrations/lupusec/index.ts | 4 + .../lupusec/lupusec.classes.client.ts | 351 +++++++++ .../lupusec/lupusec.classes.configflow.ts | 9 + .../lupusec/lupusec.classes.integration.ts | 42 +- ts/integrations/lupusec/lupusec.discovery.ts | 4 + ts/integrations/lupusec/lupusec.mapper.ts | 26 + ts/integrations/lupusec/lupusec.types.ts | 104 ++- .../lutron/.generated-by-smarthome-exchange | 1 - ts/integrations/lutron/index.ts | 4 + .../lutron/lutron.classes.client.ts | 690 ++++++++++++++++ .../lutron/lutron.classes.configflow.ts | 9 + .../lutron/lutron.classes.integration.ts | 42 +- ts/integrations/lutron/lutron.discovery.ts | 4 + ts/integrations/lutron/lutron.mapper.ts | 26 + ts/integrations/lutron/lutron.types.ts | 130 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/lutron_caseta/index.ts | 4 + .../lutron_caseta.classes.client.ts | 9 + .../lutron_caseta.classes.configflow.ts | 9 + .../lutron_caseta.classes.integration.ts | 37 +- .../lutron_caseta/lutron_caseta.discovery.ts | 4 + .../lutron_caseta/lutron_caseta.mapper.ts | 26 + .../lutron_caseta/lutron_caseta.types.ts | 117 ++- .../lw12wifi/.generated-by-smarthome-exchange | 1 - ts/integrations/lw12wifi/index.ts | 4 + .../lw12wifi/lw12wifi.classes.client.ts | 9 + .../lw12wifi/lw12wifi.classes.configflow.ts | 9 + .../lw12wifi/lw12wifi.classes.integration.ts | 31 +- .../lw12wifi/lw12wifi.discovery.ts | 4 + ts/integrations/lw12wifi/lw12wifi.mapper.ts | 26 + ts/integrations/lw12wifi/lw12wifi.types.ts | 78 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/manual_mqtt/index.ts | 4 + .../manual_mqtt/manual_mqtt.classes.client.ts | 9 + .../manual_mqtt.classes.configflow.ts | 9 + .../manual_mqtt.classes.integration.ts | 31 +- .../manual_mqtt/manual_mqtt.discovery.ts | 4 + .../manual_mqtt/manual_mqtt.mapper.ts | 26 + .../manual_mqtt/manual_mqtt.types.ts | 81 +- .../marytts/.generated-by-smarthome-exchange | 1 - ts/integrations/marytts/index.ts | 4 + .../marytts/marytts.classes.client.ts | 285 +++++++ .../marytts/marytts.classes.configflow.ts | 9 + .../marytts/marytts.classes.integration.ts | 66 +- ts/integrations/marytts/marytts.discovery.ts | 4 + ts/integrations/marytts/marytts.mapper.ts | 91 +++ ts/integrations/marytts/marytts.types.ts | 137 +++- .../maxcube/.generated-by-smarthome-exchange | 1 - ts/integrations/maxcube/index.ts | 4 + .../maxcube/maxcube.classes.client.ts | 522 ++++++++++++ .../maxcube/maxcube.classes.configflow.ts | 9 + .../maxcube/maxcube.classes.integration.ts | 39 +- ts/integrations/maxcube/maxcube.discovery.ts | 4 + ts/integrations/maxcube/maxcube.mapper.ts | 188 +++++ ts/integrations/maxcube/maxcube.types.ts | 144 +++- .../mcp/.generated-by-smarthome-exchange | 1 - ts/integrations/mcp/index.ts | 4 + ts/integrations/mcp/mcp.classes.client.ts | 252 ++++++ ts/integrations/mcp/mcp.classes.configflow.ts | 9 + .../mcp/mcp.classes.integration.ts | 43 +- ts/integrations/mcp/mcp.discovery.ts | 4 + ts/integrations/mcp/mcp.mapper.ts | 124 +++ ts/integrations/mcp/mcp.types.ts | 120 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mcp_server/index.ts | 4 + .../mcp_server/mcp_server.classes.client.ts | 274 +++++++ .../mcp_server.classes.configflow.ts | 9 + .../mcp_server.classes.integration.ts | 47 +- .../mcp_server/mcp_server.discovery.ts | 4 + .../mcp_server/mcp_server.mapper.ts | 123 +++ .../mcp_server/mcp_server.types.ts | 145 +++- .../mealie/.generated-by-smarthome-exchange | 1 - ts/integrations/mealie/index.ts | 4 + .../mealie/mealie.classes.client.ts | 407 ++++++++++ .../mealie/mealie.classes.configflow.ts | 9 + .../mealie/mealie.classes.integration.ts | 41 +- ts/integrations/mealie/mealie.discovery.ts | 4 + ts/integrations/mealie/mealie.mapper.ts | 156 ++++ ts/integrations/mealie/mealie.types.ts | 110 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/medcom_ble/index.ts | 4 + .../medcom_ble/medcom_ble.classes.client.ts | 113 +++ .../medcom_ble.classes.configflow.ts | 9 + .../medcom_ble.classes.integration.ts | 41 +- .../medcom_ble/medcom_ble.discovery.ts | 4 + .../medcom_ble/medcom_ble.mapper.ts | 63 ++ .../medcom_ble/medcom_ble.types.ts | 83 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mediaroom/index.ts | 4 + .../mediaroom/mediaroom.classes.client.ts | 360 +++++++++ .../mediaroom/mediaroom.classes.configflow.ts | 9 + .../mediaroom.classes.integration.ts | 39 +- .../mediaroom/mediaroom.discovery.ts | 4 + ts/integrations/mediaroom/mediaroom.mapper.ts | 92 +++ ts/integrations/mediaroom/mediaroom.types.ts | 110 ++- .../melnor/.generated-by-smarthome-exchange | 1 - ts/integrations/melnor/index.ts | 4 + .../melnor/melnor.classes.client.ts | 9 + .../melnor/melnor.classes.configflow.ts | 9 + .../melnor/melnor.classes.integration.ts | 35 +- ts/integrations/melnor/melnor.discovery.ts | 4 + ts/integrations/melnor/melnor.mapper.ts | 26 + ts/integrations/melnor/melnor.types.ts | 98 ++- .../mfi/.generated-by-smarthome-exchange | 1 - ts/integrations/mfi/index.ts | 4 + ts/integrations/mfi/mfi.classes.client.ts | 357 +++++++++ ts/integrations/mfi/mfi.classes.configflow.ts | 9 + .../mfi/mfi.classes.integration.ts | 37 +- ts/integrations/mfi/mfi.discovery.ts | 4 + ts/integrations/mfi/mfi.mapper.ts | 265 +++++++ ts/integrations/mfi/mfi.types.ts | 118 ++- .../mill/.generated-by-smarthome-exchange | 1 - ts/integrations/mill/index.ts | 4 + ts/integrations/mill/mill.classes.client.ts | 224 ++++++ .../mill/mill.classes.configflow.ts | 9 + .../mill/mill.classes.integration.ts | 41 +- ts/integrations/mill/mill.discovery.ts | 4 + ts/integrations/mill/mill.mapper.ts | 178 +++++ ts/integrations/mill/mill.types.ts | 114 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/minecraft_server/index.ts | 4 + .../minecraft_server.classes.client.ts | 373 +++++++++ .../minecraft_server.classes.configflow.ts | 9 + .../minecraft_server.classes.integration.ts | 43 +- .../minecraft_server.discovery.ts | 4 + .../minecraft_server.mapper.ts | 26 + .../minecraft_server.types.ts | 111 ++- .../mjpeg/.generated-by-smarthome-exchange | 1 - ts/integrations/mjpeg/index.ts | 4 + ts/integrations/mjpeg/mjpeg.classes.client.ts | 345 ++++++++ .../mjpeg/mjpeg.classes.configflow.ts | 9 + .../mjpeg/mjpeg.classes.integration.ts | 36 +- ts/integrations/mjpeg/mjpeg.discovery.ts | 4 + ts/integrations/mjpeg/mjpeg.mapper.ts | 26 + ts/integrations/mjpeg/mjpeg.types.ts | 87 +- .../moat/.generated-by-smarthome-exchange | 1 - ts/integrations/moat/index.ts | 4 + ts/integrations/moat/moat.classes.client.ts | 9 + .../moat/moat.classes.configflow.ts | 9 + .../moat/moat.classes.integration.ts | 37 +- ts/integrations/moat/moat.discovery.ts | 4 + ts/integrations/moat/moat.mapper.ts | 26 + ts/integrations/moat/moat.types.ts | 82 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mobile_app/index.ts | 4 + .../mobile_app/mobile_app.classes.client.ts | 277 +++++++ .../mobile_app.classes.configflow.ts | 9 + .../mobile_app.classes.integration.ts | 54 +- .../mobile_app/mobile_app.discovery.ts | 4 + .../mobile_app/mobile_app.mapper.ts | 26 + .../mobile_app/mobile_app.types.ts | 181 ++++- .../mochad/.generated-by-smarthome-exchange | 1 - ts/integrations/mochad/index.ts | 4 + .../mochad/mochad.classes.client.ts | 273 +++++++ .../mochad/mochad.classes.configflow.ts | 9 + .../mochad/mochad.classes.integration.ts | 39 +- ts/integrations/mochad/mochad.discovery.ts | 4 + ts/integrations/mochad/mochad.mapper.ts | 26 + ts/integrations/mochad/mochad.types.ts | 119 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/modem_callerid/index.ts | 4 + .../modem_callerid.classes.client.ts | 9 + .../modem_callerid.classes.configflow.ts | 9 + .../modem_callerid.classes.integration.ts | 37 +- .../modem_callerid.discovery.ts | 4 + .../modem_callerid/modem_callerid.mapper.ts | 26 + .../modem_callerid/modem_callerid.types.ts | 96 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/modern_forms/index.ts | 4 + .../modern_forms.classes.client.ts | 386 +++++++++ .../modern_forms.classes.configflow.ts | 9 + .../modern_forms.classes.integration.ts | 40 +- .../modern_forms/modern_forms.discovery.ts | 4 + .../modern_forms/modern_forms.mapper.ts | 26 + .../modern_forms/modern_forms.types.ts | 146 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/moehlenhoff_alpha2/index.ts | 4 + .../moehlenhoff_alpha2.classes.client.ts | 498 ++++++++++++ .../moehlenhoff_alpha2.classes.configflow.ts | 9 + .../moehlenhoff_alpha2.classes.integration.ts | 41 +- .../moehlenhoff_alpha2.discovery.ts | 4 + .../moehlenhoff_alpha2.mapper.ts | 26 + .../moehlenhoff_alpha2.types.ts | 146 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/monoprice/index.ts | 4 + .../monoprice/monoprice.classes.client.ts | 19 + .../monoprice/monoprice.classes.configflow.ts | 9 + .../monoprice.classes.integration.ts | 42 +- .../monoprice/monoprice.discovery.ts | 4 + ts/integrations/monoprice/monoprice.mapper.ts | 26 + ts/integrations/monoprice/monoprice.types.ts | 100 ++- .../mopeka/.generated-by-smarthome-exchange | 1 - ts/integrations/mopeka/index.ts | 4 + .../mopeka/mopeka.classes.client.ts | 9 + .../mopeka/mopeka.classes.configflow.ts | 9 + .../mopeka/mopeka.classes.integration.ts | 37 +- ts/integrations/mopeka/mopeka.discovery.ts | 4 + ts/integrations/mopeka/mopeka.mapper.ts | 26 + ts/integrations/mopeka/mopeka.types.ts | 82 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/motion_blinds/index.ts | 4 + .../motion_blinds.classes.client.ts | 9 + .../motion_blinds.classes.configflow.ts | 9 + .../motion_blinds.classes.integration.ts | 37 +- .../motion_blinds/motion_blinds.discovery.ts | 4 + .../motion_blinds/motion_blinds.mapper.ts | 26 + .../motion_blinds/motion_blinds.types.ts | 104 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/motionmount/index.ts | 4 + .../motionmount/motionmount.classes.client.ts | 673 ++++++++++++++++ .../motionmount.classes.configflow.ts | 9 + .../motionmount.classes.integration.ts | 42 +- .../motionmount/motionmount.discovery.ts | 4 + .../motionmount/motionmount.mapper.ts | 26 + .../motionmount/motionmount.types.ts | 102 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mqtt_eventstream/index.ts | 4 + .../mqtt_eventstream.classes.client.ts | 37 + .../mqtt_eventstream.classes.configflow.ts | 9 + .../mqtt_eventstream.classes.integration.ts | 39 +- .../mqtt_eventstream.discovery.ts | 4 + .../mqtt_eventstream.mapper.ts | 26 + .../mqtt_eventstream.types.ts | 83 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mqtt_json/index.ts | 4 + .../mqtt_json/mqtt_json.classes.client.ts | 37 + .../mqtt_json/mqtt_json.classes.configflow.ts | 9 + .../mqtt_json.classes.integration.ts | 39 +- .../mqtt_json/mqtt_json.discovery.ts | 4 + ts/integrations/mqtt_json/mqtt_json.mapper.ts | 26 + ts/integrations/mqtt_json/mqtt_json.types.ts | 83 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mqtt_room/index.ts | 4 + .../mqtt_room/mqtt_room.classes.client.ts | 37 + .../mqtt_room/mqtt_room.classes.configflow.ts | 9 + .../mqtt_room.classes.integration.ts | 39 +- .../mqtt_room/mqtt_room.discovery.ts | 4 + ts/integrations/mqtt_room/mqtt_room.mapper.ts | 26 + ts/integrations/mqtt_room/mqtt_room.types.ts | 84 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mqtt_statestream/index.ts | 4 + .../mqtt_statestream.classes.client.ts | 9 + .../mqtt_statestream.classes.configflow.ts | 9 + .../mqtt_statestream.classes.integration.ts | 33 +- .../mqtt_statestream.discovery.ts | 4 + .../mqtt_statestream.mapper.ts | 26 + .../mqtt_statestream.types.ts | 81 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/music_assistant/index.ts | 4 + .../music_assistant.classes.client.ts | 530 +++++++++++++ .../music_assistant.classes.configflow.ts | 9 + .../music_assistant.classes.integration.ts | 47 +- .../music_assistant.discovery.ts | 4 + .../music_assistant/music_assistant.mapper.ts | 216 +++++ .../music_assistant/music_assistant.types.ts | 213 ++++- .../mutesync/.generated-by-smarthome-exchange | 1 - ts/integrations/mutesync/index.ts | 4 + .../mutesync/mutesync.classes.client.ts | 150 ++++ .../mutesync/mutesync.classes.configflow.ts | 9 + .../mutesync/mutesync.classes.integration.ts | 41 +- .../mutesync/mutesync.discovery.ts | 4 + ts/integrations/mutesync/mutesync.mapper.ts | 124 +++ ts/integrations/mutesync/mutesync.types.ts | 101 ++- .../mycroft/.generated-by-smarthome-exchange | 1 - ts/integrations/mycroft/index.ts | 4 + .../mycroft/mycroft.classes.client.ts | 272 +++++++ .../mycroft/mycroft.classes.configflow.ts | 9 + .../mycroft/mycroft.classes.integration.ts | 39 +- ts/integrations/mycroft/mycroft.discovery.ts | 4 + ts/integrations/mycroft/mycroft.mapper.ts | 26 + ts/integrations/mycroft/mycroft.types.ts | 93 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/mysensors/index.ts | 4 + .../mysensors/mysensors.classes.client.ts | 679 ++++++++++++++++ .../mysensors/mysensors.classes.configflow.ts | 9 + .../mysensors.classes.integration.ts | 44 +- .../mysensors/mysensors.discovery.ts | 4 + ts/integrations/mysensors/mysensors.mapper.ts | 26 + ts/integrations/mysensors/mysensors.types.ts | 145 +++- .../mystrom/.generated-by-smarthome-exchange | 1 - ts/integrations/mystrom/index.ts | 4 + .../mystrom/mystrom.classes.client.ts | 512 ++++++++++++ .../mystrom/mystrom.classes.configflow.ts | 9 + .../mystrom/mystrom.classes.integration.ts | 43 +- ts/integrations/mystrom/mystrom.discovery.ts | 4 + ts/integrations/mystrom/mystrom.mapper.ts | 26 + ts/integrations/mystrom/mystrom.types.ts | 108 ++- .../nad/.generated-by-smarthome-exchange | 1 - ts/integrations/nad/index.ts | 4 + ts/integrations/nad/nad.classes.client.ts | 423 ++++++++++ ts/integrations/nad/nad.classes.configflow.ts | 9 + .../nad/nad.classes.integration.ts | 39 +- ts/integrations/nad/nad.discovery.ts | 4 + ts/integrations/nad/nad.mapper.ts | 195 +++++ ts/integrations/nad/nad.types.ts | 122 ++- .../nam/.generated-by-smarthome-exchange | 1 - ts/integrations/nam/index.ts | 4 + ts/integrations/nam/nam.classes.client.ts | 225 ++++++ ts/integrations/nam/nam.classes.configflow.ts | 9 + .../nam/nam.classes.integration.ts | 41 +- ts/integrations/nam/nam.discovery.ts | 4 + ts/integrations/nam/nam.mapper.ts | 184 +++++ ts/integrations/nam/nam.types.ts | 110 ++- .../nasweb/.generated-by-smarthome-exchange | 1 - ts/integrations/nasweb/index.ts | 4 + .../nasweb/nasweb.classes.client.ts | 373 +++++++++ .../nasweb/nasweb.classes.configflow.ts | 9 + .../nasweb/nasweb.classes.integration.ts | 43 +- ts/integrations/nasweb/nasweb.discovery.ts | 4 + ts/integrations/nasweb/nasweb.mapper.ts | 239 ++++++ ts/integrations/nasweb/nasweb.types.ts | 121 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/ness_alarm/index.ts | 4 + .../ness_alarm/ness_alarm.classes.client.ts | 9 + .../ness_alarm.classes.configflow.ts | 9 + .../ness_alarm.classes.integration.ts | 36 +- .../ness_alarm/ness_alarm.discovery.ts | 4 + .../ness_alarm/ness_alarm.mapper.ts | 26 + .../ness_alarm/ness_alarm.types.ts | 92 ++- .../netdata/.generated-by-smarthome-exchange | 1 - ts/integrations/netdata/index.ts | 4 + .../netdata/netdata.classes.client.ts | 245 ++++++ .../netdata/netdata.classes.configflow.ts | 9 + .../netdata/netdata.classes.integration.ts | 41 +- ts/integrations/netdata/netdata.discovery.ts | 4 + ts/integrations/netdata/netdata.mapper.ts | 26 + ts/integrations/netdata/netdata.types.ts | 93 ++- .../netgear/.generated-by-smarthome-exchange | 1 - ts/integrations/netgear/index.ts | 4 + .../netgear/netgear.classes.client.ts | 9 + .../netgear/netgear.classes.configflow.ts | 9 + .../netgear/netgear.classes.integration.ts | 36 +- ts/integrations/netgear/netgear.discovery.ts | 4 + ts/integrations/netgear/netgear.mapper.ts | 26 + ts/integrations/netgear/netgear.types.ts | 104 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/netgear_lte/index.ts | 4 + .../netgear_lte/netgear_lte.classes.client.ts | 400 ++++++++++ .../netgear_lte.classes.configflow.ts | 9 + .../netgear_lte.classes.integration.ts | 41 +- .../netgear_lte/netgear_lte.discovery.ts | 4 + .../netgear_lte/netgear_lte.mapper.ts | 26 + .../netgear_lte/netgear_lte.types.ts | 100 ++- .../netio/.generated-by-smarthome-exchange | 1 - ts/integrations/netio/index.ts | 4 + ts/integrations/netio/netio.classes.client.ts | 234 ++++++ .../netio/netio.classes.configflow.ts | 9 + .../netio/netio.classes.integration.ts | 41 +- ts/integrations/netio/netio.discovery.ts | 4 + ts/integrations/netio/netio.mapper.ts | 26 + ts/integrations/netio/netio.types.ts | 91 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/nfandroidtv/index.ts | 4 + .../nfandroidtv/nfandroidtv.classes.client.ts | 236 ++++++ .../nfandroidtv.classes.configflow.ts | 9 + .../nfandroidtv.classes.integration.ts | 41 +- .../nfandroidtv/nfandroidtv.discovery.ts | 4 + .../nfandroidtv/nfandroidtv.mapper.ts | 26 + .../nfandroidtv/nfandroidtv.types.ts | 101 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/nibe_heatpump/index.ts | 4 + .../nibe_heatpump.classes.client.ts | 548 +++++++++++++ .../nibe_heatpump.classes.configflow.ts | 9 + .../nibe_heatpump.classes.integration.ts | 41 +- .../nibe_heatpump/nibe_heatpump.discovery.ts | 4 + .../nibe_heatpump/nibe_heatpump.mapper.ts | 26 + .../nibe_heatpump/nibe_heatpump.types.ts | 158 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/niko_home_control/index.ts | 4 + .../niko_home_control.classes.client.ts | 522 ++++++++++++ .../niko_home_control.classes.configflow.ts | 9 + .../niko_home_control.classes.integration.ts | 41 +- .../niko_home_control.discovery.ts | 4 + .../niko_home_control.mapper.ts | 26 + .../niko_home_control.types.ts | 119 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/nmap_tracker/index.ts | 4 + .../nmap_tracker.classes.client.ts | 9 + .../nmap_tracker.classes.configflow.ts | 9 + .../nmap_tracker.classes.integration.ts | 36 +- .../nmap_tracker/nmap_tracker.discovery.ts | 4 + .../nmap_tracker/nmap_tracker.mapper.ts | 26 + .../nmap_tracker/nmap_tracker.types.ts | 85 +- .../nobo_hub/.generated-by-smarthome-exchange | 1 - ts/integrations/nobo_hub/index.ts | 4 + .../nobo_hub/nobo_hub.classes.client.ts | 488 ++++++++++++ .../nobo_hub/nobo_hub.classes.configflow.ts | 9 + .../nobo_hub/nobo_hub.classes.integration.ts | 42 +- .../nobo_hub/nobo_hub.discovery.ts | 4 + ts/integrations/nobo_hub/nobo_hub.mapper.ts | 258 ++++++ ts/integrations/nobo_hub/nobo_hub.types.ts | 113 ++- .../nrgkick/.generated-by-smarthome-exchange | 1 - ts/integrations/nrgkick/index.ts | 4 + .../nrgkick/nrgkick.classes.client.ts | 282 +++++++ .../nrgkick/nrgkick.classes.configflow.ts | 9 + .../nrgkick/nrgkick.classes.integration.ts | 42 +- ts/integrations/nrgkick/nrgkick.discovery.ts | 4 + ts/integrations/nrgkick/nrgkick.mapper.ts | 207 +++++ ts/integrations/nrgkick/nrgkick.types.ts | 108 ++- .../nuki/.generated-by-smarthome-exchange | 1 - ts/integrations/nuki/index.ts | 4 + ts/integrations/nuki/nuki.classes.client.ts | 294 +++++++ .../nuki/nuki.classes.configflow.ts | 9 + .../nuki/nuki.classes.integration.ts | 45 +- ts/integrations/nuki/nuki.discovery.ts | 4 + ts/integrations/nuki/nuki.mapper.ts | 275 +++++++ ts/integrations/nuki/nuki.types.ts | 118 ++- .../numato/.generated-by-smarthome-exchange | 1 - ts/integrations/numato/index.ts | 4 + .../numato/numato.classes.client.ts | 9 + .../numato/numato.classes.configflow.ts | 9 + .../numato/numato.classes.integration.ts | 36 +- ts/integrations/numato/numato.discovery.ts | 4 + ts/integrations/numato/numato.mapper.ts | 26 + ts/integrations/numato/numato.types.ts | 100 ++- .../nut/.generated-by-smarthome-exchange | 1 - ts/integrations/nut/index.ts | 4 + ts/integrations/nut/nut.classes.client.ts | 743 +++++++++++++++++ ts/integrations/nut/nut.classes.configflow.ts | 9 + .../nut/nut.classes.integration.ts | 83 +- ts/integrations/nut/nut.discovery.ts | 4 + ts/integrations/nut/nut.mapper.ts | 26 + ts/integrations/nut/nut.types.ts | 144 +++- .../nx584/.generated-by-smarthome-exchange | 1 - ts/integrations/nx584/index.ts | 4 + ts/integrations/nx584/nx584.classes.client.ts | 416 ++++++++++ .../nx584/nx584.classes.configflow.ts | 9 + .../nx584/nx584.classes.integration.ts | 77 +- ts/integrations/nx584/nx584.discovery.ts | 4 + ts/integrations/nx584/nx584.mapper.ts | 26 + ts/integrations/nx584/nx584.types.ts | 128 ++- .../nzbget/.generated-by-smarthome-exchange | 1 - ts/integrations/nzbget/index.ts | 4 + .../nzbget/nzbget.classes.client.ts | 345 ++++++++ .../nzbget/nzbget.classes.configflow.ts | 9 + .../nzbget/nzbget.classes.integration.ts | 41 +- ts/integrations/nzbget/nzbget.discovery.ts | 4 + ts/integrations/nzbget/nzbget.mapper.ts | 188 +++++ ts/integrations/nzbget/nzbget.types.ts | 116 ++- .../obihai/.generated-by-smarthome-exchange | 1 - ts/integrations/obihai/index.ts | 4 + .../obihai/obihai.classes.client.ts | 406 ++++++++++ .../obihai/obihai.classes.configflow.ts | 9 + .../obihai/obihai.classes.integration.ts | 42 +- ts/integrations/obihai/obihai.discovery.ts | 4 + ts/integrations/obihai/obihai.mapper.ts | 171 ++++ ts/integrations/obihai/obihai.types.ts | 110 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/octoprint/index.ts | 4 + .../octoprint/octoprint.classes.client.ts | 362 +++++++++ .../octoprint/octoprint.classes.configflow.ts | 9 + .../octoprint.classes.integration.ts | 41 +- .../octoprint/octoprint.discovery.ts | 4 + ts/integrations/octoprint/octoprint.mapper.ts | 270 +++++++ ts/integrations/octoprint/octoprint.types.ts | 119 ++- .../oem/.generated-by-smarthome-exchange | 1 - ts/integrations/oem/index.ts | 4 + ts/integrations/oem/oem.classes.client.ts | 268 +++++++ ts/integrations/oem/oem.classes.configflow.ts | 9 + .../oem/oem.classes.integration.ts | 39 +- ts/integrations/oem/oem.discovery.ts | 4 + ts/integrations/oem/oem.mapper.ts | 26 + ts/integrations/oem/oem.types.ts | 91 ++- .../ollama/.generated-by-smarthome-exchange | 1 - ts/integrations/ollama/index.ts | 4 + .../ollama/ollama.classes.client.ts | 189 +++++ .../ollama/ollama.classes.configflow.ts | 9 + .../ollama/ollama.classes.integration.ts | 46 +- ts/integrations/ollama/ollama.discovery.ts | 4 + ts/integrations/ollama/ollama.mapper.ts | 26 + ts/integrations/ollama/ollama.types.ts | 86 +- .../ombi/.generated-by-smarthome-exchange | 1 - ts/integrations/ombi/index.ts | 4 + ts/integrations/ombi/ombi.classes.client.ts | 338 ++++++++ .../ombi/ombi.classes.configflow.ts | 9 + .../ombi/ombi.classes.integration.ts | 41 +- ts/integrations/ombi/ombi.discovery.ts | 4 + ts/integrations/ombi/ombi.mapper.ts | 26 + ts/integrations/ombi/ombi.types.ts | 91 ++- .../onewire/.generated-by-smarthome-exchange | 1 - ts/integrations/onewire/index.ts | 4 + .../onewire/onewire.classes.client.ts | 525 ++++++++++++ .../onewire/onewire.classes.configflow.ts | 9 + .../onewire/onewire.classes.integration.ts | 43 +- ts/integrations/onewire/onewire.discovery.ts | 4 + ts/integrations/onewire/onewire.mapper.ts | 26 + ts/integrations/onewire/onewire.types.ts | 106 ++- .../onkyo/.generated-by-smarthome-exchange | 1 - ts/integrations/onkyo/index.ts | 4 + ts/integrations/onkyo/onkyo.classes.client.ts | 462 +++++++++++ .../onkyo/onkyo.classes.configflow.ts | 9 + .../onkyo/onkyo.classes.integration.ts | 45 +- ts/integrations/onkyo/onkyo.discovery.ts | 4 + ts/integrations/onkyo/onkyo.mapper.ts | 26 + ts/integrations/onkyo/onkyo.types.ts | 122 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/opendisplay/index.ts | 4 + .../opendisplay/opendisplay.classes.client.ts | 9 + .../opendisplay.classes.configflow.ts | 9 + .../opendisplay.classes.integration.ts | 39 +- .../opendisplay/opendisplay.discovery.ts | 4 + .../opendisplay/opendisplay.mapper.ts | 26 + .../opendisplay/opendisplay.types.ts | 91 ++- .../openevse/.generated-by-smarthome-exchange | 1 - ts/integrations/openevse/index.ts | 4 + .../openevse/openevse.classes.client.ts | 211 +++++ .../openevse/openevse.classes.configflow.ts | 9 + .../openevse/openevse.classes.integration.ts | 45 +- .../openevse/openevse.discovery.ts | 4 + ts/integrations/openevse/openevse.mapper.ts | 221 ++++++ ts/integrations/openevse/openevse.types.ts | 110 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/opengarage/index.ts | 4 + .../opengarage/opengarage.classes.client.ts | 170 ++++ .../opengarage.classes.configflow.ts | 9 + .../opengarage.classes.integration.ts | 41 +- .../opengarage/opengarage.discovery.ts | 4 + .../opengarage/opengarage.mapper.ts | 175 ++++ .../opengarage/opengarage.types.ts | 117 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/openhardwaremonitor/index.ts | 4 + .../openhardwaremonitor.classes.client.ts | 132 ++++ .../openhardwaremonitor.classes.configflow.ts | 9 + ...openhardwaremonitor.classes.integration.ts | 37 +- .../openhardwaremonitor.discovery.ts | 4 + .../openhardwaremonitor.mapper.ts | 188 +++++ .../openhardwaremonitor.types.ts | 89 ++- .../openhome/.generated-by-smarthome-exchange | 1 - ts/integrations/openhome/index.ts | 4 + .../openhome/openhome.classes.client.ts | 731 +++++++++++++++++ .../openhome/openhome.classes.configflow.ts | 9 + .../openhome/openhome.classes.integration.ts | 41 +- .../openhome/openhome.discovery.ts | 4 + ts/integrations/openhome/openhome.mapper.ts | 26 + ts/integrations/openhome/openhome.types.ts | 129 ++- .../opple/.generated-by-smarthome-exchange | 1 - ts/integrations/opple/index.ts | 4 + ts/integrations/opple/opple.classes.client.ts | 9 + .../opple/opple.classes.configflow.ts | 9 + .../opple/opple.classes.integration.ts | 33 +- ts/integrations/opple/opple.discovery.ts | 4 + ts/integrations/opple/opple.mapper.ts | 26 + ts/integrations/opple/opple.types.ts | 90 ++- .../oralb/.generated-by-smarthome-exchange | 1 - ts/integrations/oralb/index.ts | 4 + ts/integrations/oralb/oralb.classes.client.ts | 9 + .../oralb/oralb.classes.configflow.ts | 9 + .../oralb/oralb.classes.integration.ts | 38 +- ts/integrations/oralb/oralb.discovery.ts | 4 + ts/integrations/oralb/oralb.mapper.ts | 26 + ts/integrations/oralb/oralb.types.ts | 89 ++- .../orvibo/.generated-by-smarthome-exchange | 1 - ts/integrations/orvibo/index.ts | 4 + .../orvibo/orvibo.classes.client.ts | 20 + .../orvibo/orvibo.classes.configflow.ts | 9 + .../orvibo/orvibo.classes.integration.ts | 40 +- ts/integrations/orvibo/orvibo.discovery.ts | 4 + ts/integrations/orvibo/orvibo.mapper.ts | 26 + ts/integrations/orvibo/orvibo.types.ts | 88 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/osramlightify/index.ts | 4 + .../osramlightify.classes.client.ts | 684 ++++++++++++++++ .../osramlightify.classes.configflow.ts | 9 + .../osramlightify.classes.integration.ts | 39 +- .../osramlightify/osramlightify.discovery.ts | 4 + .../osramlightify/osramlightify.mapper.ts | 26 + .../osramlightify/osramlightify.types.ts | 151 +++- .../otbr/.generated-by-smarthome-exchange | 1 - ts/integrations/otbr/index.ts | 4 + ts/integrations/otbr/otbr.classes.client.ts | 309 ++++++++ .../otbr/otbr.classes.configflow.ts | 9 + .../otbr/otbr.classes.integration.ts | 48 +- ts/integrations/otbr/otbr.discovery.ts | 4 + ts/integrations/otbr/otbr.mapper.ts | 26 + ts/integrations/otbr/otbr.types.ts | 122 ++- .../overkiz/.generated-by-smarthome-exchange | 1 - ts/integrations/overkiz/index.ts | 4 + .../overkiz/overkiz.classes.client.ts | 436 ++++++++++ .../overkiz/overkiz.classes.configflow.ts | 9 + .../overkiz/overkiz.classes.integration.ts | 41 +- ts/integrations/overkiz/overkiz.discovery.ts | 4 + ts/integrations/overkiz/overkiz.mapper.ts | 26 + ts/integrations/overkiz/overkiz.types.ts | 138 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/overseerr/index.ts | 4 + .../overseerr/overseerr.classes.client.ts | 351 +++++++++ .../overseerr/overseerr.classes.configflow.ts | 9 + .../overseerr.classes.integration.ts | 48 +- .../overseerr/overseerr.discovery.ts | 4 + ts/integrations/overseerr/overseerr.mapper.ts | 26 + ts/integrations/overseerr/overseerr.types.ts | 99 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/owntracks/index.ts | 4 + .../owntracks/owntracks.classes.client.ts | 48 ++ .../owntracks/owntracks.classes.configflow.ts | 9 + .../owntracks.classes.integration.ts | 44 +- .../owntracks/owntracks.discovery.ts | 4 + ts/integrations/owntracks/owntracks.mapper.ts | 26 + ts/integrations/owntracks/owntracks.types.ts | 88 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/p1_monitor/index.ts | 4 + .../p1_monitor/p1_monitor.classes.client.ts | 347 ++++++++ .../p1_monitor.classes.configflow.ts | 9 + .../p1_monitor.classes.integration.ts | 41 +- .../p1_monitor/p1_monitor.discovery.ts | 4 + .../p1_monitor/p1_monitor.mapper.ts | 26 + .../p1_monitor/p1_monitor.types.ts | 86 +- .../.generated-by-smarthome-exchange | 1 - ts/integrations/palazzetti/index.ts | 4 + .../palazzetti/palazzetti.classes.client.ts | 551 +++++++++++++ .../palazzetti.classes.configflow.ts | 9 + .../palazzetti.classes.integration.ts | 41 +- .../palazzetti/palazzetti.discovery.ts | 4 + .../palazzetti/palazzetti.mapper.ts | 26 + .../palazzetti/palazzetti.types.ts | 107 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/panasonic_bluray/index.ts | 4 + .../panasonic_bluray.classes.client.ts | 341 ++++++++ .../panasonic_bluray.classes.configflow.ts | 9 + .../panasonic_bluray.classes.integration.ts | 39 +- .../panasonic_bluray.discovery.ts | 4 + .../panasonic_bluray.mapper.ts | 26 + .../panasonic_bluray.types.ts | 101 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/panasonic_viera/index.ts | 4 + .../panasonic_viera.classes.client.ts | 426 ++++++++++ .../panasonic_viera.classes.configflow.ts | 9 + .../panasonic_viera.classes.integration.ts | 39 +- .../panasonic_viera.discovery.ts | 4 + .../panasonic_viera/panasonic_viera.mapper.ts | 173 ++++ .../panasonic_viera/panasonic_viera.types.ts | 115 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/paperless_ngx/index.ts | 4 + .../paperless_ngx.classes.client.ts | 176 +++++ .../paperless_ngx.classes.configflow.ts | 9 + .../paperless_ngx.classes.integration.ts | 42 +- .../paperless_ngx/paperless_ngx.discovery.ts | 4 + .../paperless_ngx/paperless_ngx.mapper.ts | 189 +++++ .../paperless_ngx/paperless_ngx.types.ts | 95 ++- .../peblar/.generated-by-smarthome-exchange | 1 - ts/integrations/peblar/index.ts | 4 + .../peblar/peblar.classes.client.ts | 453 +++++++++++ .../peblar/peblar.classes.configflow.ts | 9 + .../peblar/peblar.classes.integration.ts | 42 +- ts/integrations/peblar/peblar.discovery.ts | 4 + ts/integrations/peblar/peblar.mapper.ts | 347 ++++++++ ts/integrations/peblar/peblar.types.ts | 125 ++- .../pencom/.generated-by-smarthome-exchange | 1 - ts/integrations/pencom/index.ts | 4 + .../pencom/pencom.classes.client.ts | 339 ++++++++ .../pencom/pencom.classes.configflow.ts | 9 + .../pencom/pencom.classes.integration.ts | 39 +- ts/integrations/pencom/pencom.discovery.ts | 4 + ts/integrations/pencom/pencom.mapper.ts | 164 ++++ ts/integrations/pencom/pencom.types.ts | 110 ++- .../pglab/.generated-by-smarthome-exchange | 1 - ts/integrations/pglab/index.ts | 4 + ts/integrations/pglab/pglab.classes.client.ts | 9 + .../pglab/pglab.classes.configflow.ts | 9 + .../pglab/pglab.classes.integration.ts | 38 +- ts/integrations/pglab/pglab.discovery.ts | 4 + ts/integrations/pglab/pglab.mapper.ts | 26 + ts/integrations/pglab/pglab.types.ts | 110 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/philips_js/index.ts | 4 + .../philips_js/philips_js.classes.client.ts | 590 ++++++++++++++ .../philips_js.classes.configflow.ts | 9 + .../philips_js.classes.integration.ts | 41 +- .../philips_js/philips_js.discovery.ts | 4 + .../philips_js/philips_js.mapper.ts | 291 +++++++ .../philips_js/philips_js.types.ts | 175 +++- .../picotts/.generated-by-smarthome-exchange | 1 - ts/integrations/picotts/index.ts | 4 + .../picotts/picotts.classes.client.ts | 103 +++ .../picotts/picotts.classes.configflow.ts | 9 + .../picotts/picotts.classes.integration.ts | 39 +- ts/integrations/picotts/picotts.discovery.ts | 4 + ts/integrations/picotts/picotts.mapper.ts | 26 + ts/integrations/picotts/picotts.types.ts | 80 +- .../pilight/.generated-by-smarthome-exchange | 1 - ts/integrations/pilight/index.ts | 4 + .../pilight/pilight.classes.client.ts | 443 +++++++++++ .../pilight/pilight.classes.configflow.ts | 9 + .../pilight/pilight.classes.integration.ts | 51 +- ts/integrations/pilight/pilight.discovery.ts | 4 + ts/integrations/pilight/pilight.mapper.ts | 51 ++ ts/integrations/pilight/pilight.types.ts | 106 ++- .../ping/.generated-by-smarthome-exchange | 1 - ts/integrations/ping/index.ts | 4 + ts/integrations/ping/ping.classes.client.ts | 103 +++ .../ping/ping.classes.configflow.ts | 9 + .../ping/ping.classes.integration.ts | 42 +- ts/integrations/ping/ping.discovery.ts | 4 + ts/integrations/ping/ping.mapper.ts | 26 + ts/integrations/ping/ping.types.ts | 87 +- .../pioneer/.generated-by-smarthome-exchange | 1 - ts/integrations/pioneer/index.ts | 4 + .../pioneer/pioneer.classes.client.ts | 462 +++++++++++ .../pioneer/pioneer.classes.configflow.ts | 9 + .../pioneer/pioneer.classes.integration.ts | 37 +- ts/integrations/pioneer/pioneer.discovery.ts | 4 + ts/integrations/pioneer/pioneer.mapper.ts | 26 + ts/integrations/pioneer/pioneer.types.ts | 99 ++- .../pjlink/.generated-by-smarthome-exchange | 1 - ts/integrations/pjlink/index.ts | 4 + .../pjlink/pjlink.classes.client.ts | 485 ++++++++++++ .../pjlink/pjlink.classes.configflow.ts | 9 + .../pjlink/pjlink.classes.integration.ts | 39 +- ts/integrations/pjlink/pjlink.discovery.ts | 4 + ts/integrations/pjlink/pjlink.mapper.ts | 26 + ts/integrations/pjlink/pjlink.types.ts | 96 ++- .../plugwise/.generated-by-smarthome-exchange | 1 - ts/integrations/plugwise/index.ts | 4 + .../plugwise/plugwise.classes.client.ts | 340 ++++++++ .../plugwise/plugwise.classes.configflow.ts | 9 + .../plugwise/plugwise.classes.integration.ts | 43 +- .../plugwise/plugwise.discovery.ts | 4 + ts/integrations/plugwise/plugwise.mapper.ts | 26 + ts/integrations/plugwise/plugwise.types.ts | 128 ++- 1830 files changed, 111386 insertions(+), 5722 deletions(-) create mode 100644 test/flexit_bacnet/test.flexit_bacnet.node.ts create mode 100644 test/flic/test.flic.node.ts create mode 100644 test/flux_led/test.flux_led.node.ts create mode 100644 test/folder/alpha.txt create mode 100644 test/folder/test.folder.node.ts create mode 100644 test/folder_watcher/event.yaml create mode 100644 test/folder_watcher/ignore.txt create mode 100644 test/folder_watcher/test.folder_watcher.node.ts create mode 100644 test/fortios/test.fortios.node.ts create mode 100644 test/fritzbox/test.fritzbox.node.ts create mode 100644 test/futurenow/test.futurenow.node.ts create mode 100644 test/gardena_bluetooth/test.gardena_bluetooth.node.ts create mode 100644 test/gc100/test.gc100.node.ts create mode 100644 test/generic/test.generic.node.ts create mode 100644 test/geniushub/test.geniushub.node.ts create mode 100644 test/goalzero/test.goalzero.node.ts create mode 100644 test/gogogate2/test.gogogate2.node.ts create mode 100644 test/google_wifi/test.google_wifi.node.ts create mode 100644 test/govee_ble/test.govee_ble.node.ts create mode 100644 test/govee_light_local/test.govee_light_local.node.ts create mode 100644 test/gpsd/test.gpsd.node.ts create mode 100644 test/graphite/test.graphite.node.ts create mode 100644 test/gree/test.gree.node.ts create mode 100644 test/greeneye_monitor/test.greeneye_monitor.node.ts create mode 100644 test/greenwave/test.greenwave.node.ts create mode 100644 test/gtfs/test.gtfs.node.ts create mode 100644 test/guardian/test.guardian.node.ts create mode 100644 test/hassio/test.hassio.node.ts create mode 100644 test/hdfury/test.hdfury.node.ts create mode 100644 test/hdmi_cec/test.hdmi_cec.node.ts create mode 100644 test/heatmiser/test.heatmiser.node.ts create mode 100644 test/hegel/test.hegel.node.ts create mode 100644 test/hikvisioncam/test.hikvisioncam.node.ts create mode 100644 test/hisense_aehw4a1/test.hisense_aehw4a1.node.ts create mode 100644 test/hitron_coda/test.hitron_coda.node.ts create mode 100644 test/hlk_sw16/test.hlk_sw16.node.ts create mode 100644 test/holiday/test.holiday.node.ts create mode 100644 test/homee/test.homee.node.ts create mode 100644 test/homekit/test.homekit.node.ts create mode 100644 test/homevolt/test.homevolt.node.ts create mode 100644 test/homeworks/test.homeworks.node.ts create mode 100644 test/horizon/test.horizon.node.ts create mode 100644 test/hp_ilo/test.hp_ilo.node.ts create mode 100644 test/hr_energy_qube/test.hr_energy_qube.node.ts create mode 100644 test/hue_ble/test.hue_ble.node.ts create mode 100644 test/husqvarna_automower_ble/test.husqvarna_automower_ble.node.ts create mode 100644 test/ialarm/test.ialarm.node.ts create mode 100644 test/iammeter/test.iammeter.node.ts create mode 100644 test/ibeacon/test.ibeacon.node.ts create mode 100644 test/idasen_desk/test.idasen_desk.node.ts create mode 100644 test/idteck_prox/test.idteck_prox.node.ts create mode 100644 test/iglo/test.iglo.node.ts create mode 100644 test/ihc/test.ihc.node.ts create mode 100644 test/imeon_inverter/test.imeon_inverter.client.node.ts create mode 100644 test/imeon_inverter/test.imeon_inverter.node.ts create mode 100644 test/immich/test.immich.client.node.ts create mode 100644 test/immich/test.immich.node.ts create mode 100644 test/improv_ble/test.improv_ble.node.ts create mode 100644 test/incomfort/test.incomfort.node.ts create mode 100644 test/indevolt/test.indevolt.node.ts create mode 100644 test/inels/test.inels.node.ts create mode 100644 test/influxdb/test.influxdb.node.ts create mode 100644 test/inkbird/test.inkbird.node.ts create mode 100644 test/insteon/test.insteon.node.ts create mode 100644 test/intellifire/test.intellifire.node.ts create mode 100644 test/iometer/test.iometer.node.ts create mode 100644 test/iotawatt/test.iotawatt.node.ts create mode 100644 test/iperf3/test.iperf3.node.ts create mode 100644 test/iron_os/test.iron_os.node.ts create mode 100644 test/iskra/test.iskra.node.ts create mode 100644 test/isy994/test.isy994.node.ts create mode 100644 test/itunes/test.itunes.node.ts create mode 100644 test/izone/test.izone.node.ts create mode 100644 test/jvc_projector/test.jvc_projector.node.ts create mode 100644 test/kaleidescape/test.kaleidescape.node.ts create mode 100644 test/kankun/test.kankun.node.ts create mode 100644 test/keba/test.keba.node.ts create mode 100644 test/keenetic_ndms2/test.keenetic_ndms2.node.ts create mode 100644 test/kef/test.kef.node.ts create mode 100644 test/kegtron/test.kegtron.node.ts create mode 100644 test/keyboard_remote/test.keyboard_remote.node.ts create mode 100644 test/kiosker/test.kiosker.node.ts create mode 100644 test/kira/test.kira.node.ts create mode 100644 test/kmtronic/test.kmtronic.node.ts create mode 100644 test/konnected/test.konnected.node.ts create mode 100644 test/kostal_plenticore/test.kostal_plenticore.client.node.ts create mode 100644 test/kostal_plenticore/test.kostal_plenticore.node.ts create mode 100644 test/kulersky/test.kulersky.node.ts create mode 100644 test/kulersky/test.kulersky.unsupported.node.ts create mode 100644 test/kwb/test.kwb.client.node.ts create mode 100644 test/kwb/test.kwb.node.ts create mode 100644 test/lacrosse/test.lacrosse.node.ts create mode 100644 test/lametric/test.lametric.node.ts create mode 100644 test/landisgyr_heat_meter/test.landisgyr_heat_meter.node.ts create mode 100644 test/lannouncer/test.lannouncer.node.ts create mode 100644 test/lcn/test.lcn.node.ts create mode 100644 test/ld2410_ble/test.ld2410_ble.node.ts create mode 100644 test/leaone/test.leaone.node.ts create mode 100644 test/led_ble/test.led_ble.node.ts create mode 100644 test/lektrico/test.lektrico.node.ts create mode 100644 test/lg_netcast/test.lg_netcast.node.ts create mode 100644 test/lg_soundbar/test.lg_soundbar.node.ts create mode 100644 test/libre_hardware_monitor/test.libre_hardware_monitor.node.ts create mode 100644 test/lidarr/test.lidarr.node.ts create mode 100644 test/lifx/test.lifx.node.ts create mode 100644 test/linksys_smart/test.linksys_smart.node.ts create mode 100644 test/linux_battery/test.linux_battery.node.ts create mode 100644 test/litejet/test.litejet.node.ts create mode 100644 test/livisi/test.livisi.node.ts create mode 100644 test/local_calendar/sample.ics create mode 100644 test/local_calendar/test.local_calendar.node.ts create mode 100644 test/local_file/sample-one.png create mode 100644 test/local_file/sample-two.jpg create mode 100644 test/local_file/test.local_file.node.ts create mode 100644 test/local_ip/test.local_ip.node.ts create mode 100644 test/local_todo/sample.ics create mode 100644 test/local_todo/test.local_todo.node.ts create mode 100644 test/locative/test.locative.node.ts create mode 100644 test/lookin/test.lookin.node.ts create mode 100644 test/loqed/test.loqed.client_runtime.node.ts create mode 100644 test/loqed/test.loqed.node.ts create mode 100644 test/luci/test.luci.client_runtime.node.ts create mode 100644 test/luci/test.luci.node.ts create mode 100644 test/lunatone/test.lunatone.client_runtime.node.ts create mode 100644 test/lunatone/test.lunatone.node.ts create mode 100644 test/lupusec/test.lupusec.node.ts create mode 100644 test/lutron/test.lutron.node.ts create mode 100644 test/lutron_caseta/test.lutron_caseta.node.ts create mode 100644 test/lw12wifi/test.lw12wifi.node.ts create mode 100644 test/lw12wifi/test.lw12wifi.unsupported.node.ts create mode 100644 test/manual_mqtt/test.manual_mqtt.node.ts create mode 100644 test/manual_mqtt/test.manual_mqtt.unsupported.node.ts create mode 100644 test/marytts/test.marytts.client_runtime.node.ts create mode 100644 test/marytts/test.marytts.node.ts create mode 100644 test/maxcube/test.maxcube.node.ts create mode 100644 test/mcp/test.mcp.node.ts create mode 100644 test/mcp_server/test.mcp_server.node.ts create mode 100644 test/mealie/test.mealie.client.node.ts create mode 100644 test/mealie/test.mealie.node.ts create mode 100644 test/medcom_ble/test.medcom_ble.client.node.ts create mode 100644 test/medcom_ble/test.medcom_ble.node.ts create mode 100644 test/mediaroom/test.mediaroom.client.node.ts create mode 100644 test/mediaroom/test.mediaroom.node.ts create mode 100644 test/melnor/test.melnor.node.ts create mode 100644 test/mfi/test.mfi.node.ts create mode 100644 test/mill/test.mill.node.ts create mode 100644 test/minecraft_server/test.minecraft_server.node.ts create mode 100644 test/mjpeg/test.mjpeg.node.ts create mode 100644 test/moat/test.moat.node.ts create mode 100644 test/mobile_app/test.mobile_app.node.ts create mode 100644 test/mochad/test.mochad.node.ts create mode 100644 test/modem_callerid/test.modem_callerid.node.ts create mode 100644 test/modern_forms/test.modern_forms.node.ts create mode 100644 test/moehlenhoff_alpha2/test.moehlenhoff_alpha2.node.ts create mode 100644 test/monoprice/test.monoprice.node.ts create mode 100644 test/mopeka/test.mopeka.node.ts create mode 100644 test/motion_blinds/test.motion_blinds.node.ts create mode 100644 test/motionmount/test.motionmount.node.ts create mode 100644 test/mqtt_eventstream/test.mqtt_eventstream.node.ts create mode 100644 test/mqtt_json/test.mqtt_json.node.ts create mode 100644 test/mqtt_room/test.mqtt_room.node.ts create mode 100644 test/mqtt_statestream/test.mqtt_statestream.node.ts create mode 100644 test/music_assistant/test.music_assistant.node.ts create mode 100644 test/mutesync/test.mutesync.node.ts create mode 100644 test/mycroft/test.mycroft.node.ts create mode 100644 test/mysensors/test.mysensors.node.ts create mode 100644 test/mystrom/test.mystrom.node.ts create mode 100644 test/nad/test.nad.node.ts create mode 100644 test/nam/test.nam.node.ts create mode 100644 test/nasweb/test.nasweb.node.ts create mode 100644 test/ness_alarm/test.ness_alarm.node.ts create mode 100644 test/netdata/test.netdata.node.ts create mode 100644 test/netgear/test.netgear.node.ts create mode 100644 test/netgear_lte/test.netgear_lte.node.ts create mode 100644 test/netio/test.netio.node.ts create mode 100644 test/nfandroidtv/test.nfandroidtv.node.ts create mode 100644 test/nibe_heatpump/test.nibe_heatpump.node.ts create mode 100644 test/niko_home_control/test.niko_home_control.node.ts create mode 100644 test/nmap_tracker/test.nmap_tracker.node.ts create mode 100644 test/nobo_hub/test.nobo_hub.node.ts create mode 100644 test/nrgkick/test.nrgkick.node.ts create mode 100644 test/nuki/test.nuki.node.ts create mode 100644 test/numato/test.numato.node.ts create mode 100644 test/nut/test.nut.node.ts create mode 100644 test/nx584/test.nx584.node.ts create mode 100644 test/nzbget/test.nzbget.node.ts create mode 100644 test/obihai/test.obihai.node.ts create mode 100644 test/octoprint/test.octoprint.node.ts create mode 100644 test/oem/test.oem.node.ts create mode 100644 test/ollama/test.ollama.node.ts create mode 100644 test/ombi/test.ombi.node.ts create mode 100644 test/onewire/test.onewire.node.ts create mode 100644 test/onkyo/test.onkyo.node.ts create mode 100644 test/opendisplay/test.opendisplay.node.ts create mode 100644 test/openevse/test.openevse.node.ts create mode 100644 test/opengarage/test.opengarage.node.ts create mode 100644 test/openhardwaremonitor/test.openhardwaremonitor.node.ts create mode 100644 test/openhome/test.openhome.node.ts create mode 100644 test/opple/test.opple.node.ts create mode 100644 test/oralb/test.oralb.node.ts create mode 100644 test/orvibo/test.orvibo.node.ts create mode 100644 test/osramlightify/test.osramlightify.node.ts create mode 100644 test/otbr/test.otbr.node.ts create mode 100644 test/overkiz/test.overkiz.node.ts create mode 100644 test/overseerr/test.overseerr.node.ts create mode 100644 test/owntracks/test.owntracks.node.ts create mode 100644 test/p1_monitor/test.p1_monitor.node.ts create mode 100644 test/palazzetti/test.palazzetti.node.ts create mode 100644 test/panasonic_bluray/test.panasonic_bluray.node.ts create mode 100644 test/panasonic_viera/test.panasonic_viera.node.ts create mode 100644 test/paperless_ngx/test.paperless_ngx.node.ts create mode 100644 test/peblar/test.peblar.node.ts create mode 100644 test/pencom/test.pencom.node.ts create mode 100644 test/pglab/test.pglab.node.ts create mode 100644 test/philips_js/test.philips_js.node.ts create mode 100644 test/picotts/test.picotts.node.ts create mode 100644 test/pilight/test.pilight.node.ts create mode 100644 test/ping/test.ping.node.ts create mode 100644 test/pioneer/test.pioneer.node.ts create mode 100644 test/pjlink/test.pjlink.node.ts create mode 100644 test/plugwise/test.plugwise.node.ts delete mode 100644 ts/integrations/flexit_bacnet/.generated-by-smarthome-exchange create mode 100644 ts/integrations/flexit_bacnet/flexit_bacnet.classes.client.ts create mode 100644 ts/integrations/flexit_bacnet/flexit_bacnet.classes.configflow.ts create mode 100644 ts/integrations/flexit_bacnet/flexit_bacnet.discovery.ts create mode 100644 ts/integrations/flexit_bacnet/flexit_bacnet.mapper.ts delete mode 100644 ts/integrations/flic/.generated-by-smarthome-exchange create mode 100644 ts/integrations/flic/flic.classes.client.ts create mode 100644 ts/integrations/flic/flic.classes.configflow.ts create mode 100644 ts/integrations/flic/flic.discovery.ts create mode 100644 ts/integrations/flic/flic.mapper.ts delete mode 100644 ts/integrations/flux_led/.generated-by-smarthome-exchange create mode 100644 ts/integrations/flux_led/flux_led.classes.client.ts create mode 100644 ts/integrations/flux_led/flux_led.classes.configflow.ts create mode 100644 ts/integrations/flux_led/flux_led.discovery.ts create mode 100644 ts/integrations/flux_led/flux_led.mapper.ts delete mode 100644 ts/integrations/folder/.generated-by-smarthome-exchange create mode 100644 ts/integrations/folder/folder.classes.client.ts create mode 100644 ts/integrations/folder/folder.classes.configflow.ts create mode 100644 ts/integrations/folder/folder.discovery.ts create mode 100644 ts/integrations/folder/folder.mapper.ts delete mode 100644 ts/integrations/folder_watcher/.generated-by-smarthome-exchange create mode 100644 ts/integrations/folder_watcher/folder_watcher.classes.client.ts create mode 100644 ts/integrations/folder_watcher/folder_watcher.classes.configflow.ts create mode 100644 ts/integrations/folder_watcher/folder_watcher.discovery.ts create mode 100644 ts/integrations/folder_watcher/folder_watcher.mapper.ts delete mode 100644 ts/integrations/fortios/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fortios/fortios.classes.client.ts create mode 100644 ts/integrations/fortios/fortios.classes.configflow.ts create mode 100644 ts/integrations/fortios/fortios.discovery.ts create mode 100644 ts/integrations/fortios/fortios.mapper.ts delete mode 100644 ts/integrations/fritzbox/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fritzbox/fritzbox.classes.client.ts create mode 100644 ts/integrations/fritzbox/fritzbox.classes.configflow.ts create mode 100644 ts/integrations/fritzbox/fritzbox.discovery.ts create mode 100644 ts/integrations/fritzbox/fritzbox.mapper.ts delete mode 100644 ts/integrations/futurenow/.generated-by-smarthome-exchange create mode 100644 ts/integrations/futurenow/futurenow.classes.client.ts create mode 100644 ts/integrations/futurenow/futurenow.classes.configflow.ts create mode 100644 ts/integrations/futurenow/futurenow.discovery.ts create mode 100644 ts/integrations/futurenow/futurenow.mapper.ts delete mode 100644 ts/integrations/gardena_bluetooth/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gardena_bluetooth/gardena_bluetooth.classes.client.ts create mode 100644 ts/integrations/gardena_bluetooth/gardena_bluetooth.classes.configflow.ts create mode 100644 ts/integrations/gardena_bluetooth/gardena_bluetooth.discovery.ts create mode 100644 ts/integrations/gardena_bluetooth/gardena_bluetooth.mapper.ts delete mode 100644 ts/integrations/gc100/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gc100/gc100.classes.client.ts create mode 100644 ts/integrations/gc100/gc100.classes.configflow.ts create mode 100644 ts/integrations/gc100/gc100.discovery.ts create mode 100644 ts/integrations/gc100/gc100.mapper.ts delete mode 100644 ts/integrations/generic/.generated-by-smarthome-exchange create mode 100644 ts/integrations/generic/generic.classes.client.ts create mode 100644 ts/integrations/generic/generic.classes.configflow.ts create mode 100644 ts/integrations/generic/generic.discovery.ts create mode 100644 ts/integrations/generic/generic.mapper.ts delete mode 100644 ts/integrations/geniushub/.generated-by-smarthome-exchange create mode 100644 ts/integrations/geniushub/geniushub.classes.client.ts create mode 100644 ts/integrations/geniushub/geniushub.classes.configflow.ts create mode 100644 ts/integrations/geniushub/geniushub.discovery.ts create mode 100644 ts/integrations/geniushub/geniushub.mapper.ts delete mode 100644 ts/integrations/goalzero/.generated-by-smarthome-exchange create mode 100644 ts/integrations/goalzero/goalzero.classes.client.ts create mode 100644 ts/integrations/goalzero/goalzero.classes.configflow.ts create mode 100644 ts/integrations/goalzero/goalzero.discovery.ts create mode 100644 ts/integrations/goalzero/goalzero.mapper.ts delete mode 100644 ts/integrations/gogogate2/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gogogate2/gogogate2.classes.client.ts create mode 100644 ts/integrations/gogogate2/gogogate2.classes.configflow.ts create mode 100644 ts/integrations/gogogate2/gogogate2.discovery.ts create mode 100644 ts/integrations/gogogate2/gogogate2.mapper.ts delete mode 100644 ts/integrations/google_wifi/.generated-by-smarthome-exchange create mode 100644 ts/integrations/google_wifi/google_wifi.classes.client.ts create mode 100644 ts/integrations/google_wifi/google_wifi.classes.configflow.ts create mode 100644 ts/integrations/google_wifi/google_wifi.discovery.ts create mode 100644 ts/integrations/google_wifi/google_wifi.mapper.ts delete mode 100644 ts/integrations/govee_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/govee_ble/govee_ble.classes.client.ts create mode 100644 ts/integrations/govee_ble/govee_ble.classes.configflow.ts create mode 100644 ts/integrations/govee_ble/govee_ble.discovery.ts create mode 100644 ts/integrations/govee_ble/govee_ble.mapper.ts delete mode 100644 ts/integrations/govee_light_local/.generated-by-smarthome-exchange create mode 100644 ts/integrations/govee_light_local/govee_light_local.classes.client.ts create mode 100644 ts/integrations/govee_light_local/govee_light_local.classes.configflow.ts create mode 100644 ts/integrations/govee_light_local/govee_light_local.discovery.ts create mode 100644 ts/integrations/govee_light_local/govee_light_local.mapper.ts delete mode 100644 ts/integrations/gpsd/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gpsd/gpsd.classes.client.ts create mode 100644 ts/integrations/gpsd/gpsd.classes.configflow.ts create mode 100644 ts/integrations/gpsd/gpsd.discovery.ts create mode 100644 ts/integrations/gpsd/gpsd.mapper.ts delete mode 100644 ts/integrations/graphite/.generated-by-smarthome-exchange create mode 100644 ts/integrations/graphite/graphite.classes.client.ts create mode 100644 ts/integrations/graphite/graphite.classes.configflow.ts create mode 100644 ts/integrations/graphite/graphite.discovery.ts create mode 100644 ts/integrations/graphite/graphite.mapper.ts delete mode 100644 ts/integrations/gree/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gree/gree.classes.client.ts create mode 100644 ts/integrations/gree/gree.classes.configflow.ts create mode 100644 ts/integrations/gree/gree.discovery.ts create mode 100644 ts/integrations/gree/gree.mapper.ts delete mode 100644 ts/integrations/greeneye_monitor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/greeneye_monitor/greeneye_monitor.classes.client.ts create mode 100644 ts/integrations/greeneye_monitor/greeneye_monitor.classes.configflow.ts create mode 100644 ts/integrations/greeneye_monitor/greeneye_monitor.discovery.ts create mode 100644 ts/integrations/greeneye_monitor/greeneye_monitor.mapper.ts delete mode 100644 ts/integrations/greenwave/.generated-by-smarthome-exchange create mode 100644 ts/integrations/greenwave/greenwave.classes.client.ts create mode 100644 ts/integrations/greenwave/greenwave.classes.configflow.ts create mode 100644 ts/integrations/greenwave/greenwave.discovery.ts create mode 100644 ts/integrations/greenwave/greenwave.mapper.ts delete mode 100644 ts/integrations/gtfs/.generated-by-smarthome-exchange create mode 100644 ts/integrations/gtfs/gtfs.classes.client.ts create mode 100644 ts/integrations/gtfs/gtfs.classes.configflow.ts create mode 100644 ts/integrations/gtfs/gtfs.discovery.ts create mode 100644 ts/integrations/gtfs/gtfs.mapper.ts delete mode 100644 ts/integrations/guardian/.generated-by-smarthome-exchange create mode 100644 ts/integrations/guardian/guardian.classes.client.ts create mode 100644 ts/integrations/guardian/guardian.classes.configflow.ts create mode 100644 ts/integrations/guardian/guardian.discovery.ts create mode 100644 ts/integrations/guardian/guardian.mapper.ts delete mode 100644 ts/integrations/hassio/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hassio/hassio.classes.client.ts create mode 100644 ts/integrations/hassio/hassio.classes.configflow.ts create mode 100644 ts/integrations/hassio/hassio.discovery.ts create mode 100644 ts/integrations/hassio/hassio.mapper.ts delete mode 100644 ts/integrations/hdfury/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hdfury/hdfury.classes.client.ts create mode 100644 ts/integrations/hdfury/hdfury.classes.configflow.ts create mode 100644 ts/integrations/hdfury/hdfury.discovery.ts create mode 100644 ts/integrations/hdfury/hdfury.mapper.ts delete mode 100644 ts/integrations/hdmi_cec/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hdmi_cec/hdmi_cec.classes.client.ts create mode 100644 ts/integrations/hdmi_cec/hdmi_cec.classes.configflow.ts create mode 100644 ts/integrations/hdmi_cec/hdmi_cec.discovery.ts create mode 100644 ts/integrations/hdmi_cec/hdmi_cec.mapper.ts delete mode 100644 ts/integrations/heatmiser/.generated-by-smarthome-exchange create mode 100644 ts/integrations/heatmiser/heatmiser.classes.client.ts create mode 100644 ts/integrations/heatmiser/heatmiser.classes.configflow.ts create mode 100644 ts/integrations/heatmiser/heatmiser.discovery.ts create mode 100644 ts/integrations/heatmiser/heatmiser.mapper.ts delete mode 100644 ts/integrations/hegel/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hegel/hegel.classes.client.ts create mode 100644 ts/integrations/hegel/hegel.classes.configflow.ts create mode 100644 ts/integrations/hegel/hegel.discovery.ts create mode 100644 ts/integrations/hegel/hegel.mapper.ts delete mode 100644 ts/integrations/hikvisioncam/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hikvisioncam/hikvisioncam.classes.client.ts create mode 100644 ts/integrations/hikvisioncam/hikvisioncam.classes.configflow.ts create mode 100644 ts/integrations/hikvisioncam/hikvisioncam.discovery.ts create mode 100644 ts/integrations/hikvisioncam/hikvisioncam.mapper.ts delete mode 100644 ts/integrations/hisense_aehw4a1/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hisense_aehw4a1/hisense_aehw4a1.classes.client.ts create mode 100644 ts/integrations/hisense_aehw4a1/hisense_aehw4a1.classes.configflow.ts create mode 100644 ts/integrations/hisense_aehw4a1/hisense_aehw4a1.discovery.ts create mode 100644 ts/integrations/hisense_aehw4a1/hisense_aehw4a1.mapper.ts delete mode 100644 ts/integrations/hitron_coda/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hitron_coda/hitron_coda.classes.client.ts create mode 100644 ts/integrations/hitron_coda/hitron_coda.classes.configflow.ts create mode 100644 ts/integrations/hitron_coda/hitron_coda.discovery.ts create mode 100644 ts/integrations/hitron_coda/hitron_coda.mapper.ts delete mode 100644 ts/integrations/hlk_sw16/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hlk_sw16/hlk_sw16.classes.client.ts create mode 100644 ts/integrations/hlk_sw16/hlk_sw16.classes.configflow.ts create mode 100644 ts/integrations/hlk_sw16/hlk_sw16.discovery.ts create mode 100644 ts/integrations/hlk_sw16/hlk_sw16.mapper.ts delete mode 100644 ts/integrations/holiday/.generated-by-smarthome-exchange create mode 100644 ts/integrations/holiday/holiday.classes.client.ts create mode 100644 ts/integrations/holiday/holiday.classes.configflow.ts create mode 100644 ts/integrations/holiday/holiday.discovery.ts create mode 100644 ts/integrations/holiday/holiday.mapper.ts delete mode 100644 ts/integrations/homee/.generated-by-smarthome-exchange create mode 100644 ts/integrations/homee/homee.classes.client.ts create mode 100644 ts/integrations/homee/homee.classes.configflow.ts create mode 100644 ts/integrations/homee/homee.discovery.ts create mode 100644 ts/integrations/homee/homee.mapper.ts delete mode 100644 ts/integrations/homekit/.generated-by-smarthome-exchange create mode 100644 ts/integrations/homekit/homekit.classes.client.ts create mode 100644 ts/integrations/homekit/homekit.classes.configflow.ts create mode 100644 ts/integrations/homekit/homekit.discovery.ts create mode 100644 ts/integrations/homekit/homekit.mapper.ts delete mode 100644 ts/integrations/homevolt/.generated-by-smarthome-exchange create mode 100644 ts/integrations/homevolt/homevolt.classes.client.ts create mode 100644 ts/integrations/homevolt/homevolt.classes.configflow.ts create mode 100644 ts/integrations/homevolt/homevolt.discovery.ts create mode 100644 ts/integrations/homevolt/homevolt.mapper.ts delete mode 100644 ts/integrations/homeworks/.generated-by-smarthome-exchange create mode 100644 ts/integrations/homeworks/homeworks.classes.client.ts create mode 100644 ts/integrations/homeworks/homeworks.classes.configflow.ts create mode 100644 ts/integrations/homeworks/homeworks.discovery.ts create mode 100644 ts/integrations/homeworks/homeworks.mapper.ts delete mode 100644 ts/integrations/horizon/.generated-by-smarthome-exchange create mode 100644 ts/integrations/horizon/horizon.classes.client.ts create mode 100644 ts/integrations/horizon/horizon.classes.configflow.ts create mode 100644 ts/integrations/horizon/horizon.discovery.ts create mode 100644 ts/integrations/horizon/horizon.mapper.ts delete mode 100644 ts/integrations/hp_ilo/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hp_ilo/hp_ilo.classes.client.ts create mode 100644 ts/integrations/hp_ilo/hp_ilo.classes.configflow.ts create mode 100644 ts/integrations/hp_ilo/hp_ilo.discovery.ts create mode 100644 ts/integrations/hp_ilo/hp_ilo.mapper.ts delete mode 100644 ts/integrations/hr_energy_qube/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hr_energy_qube/hr_energy_qube.classes.client.ts create mode 100644 ts/integrations/hr_energy_qube/hr_energy_qube.classes.configflow.ts create mode 100644 ts/integrations/hr_energy_qube/hr_energy_qube.discovery.ts create mode 100644 ts/integrations/hr_energy_qube/hr_energy_qube.mapper.ts delete mode 100644 ts/integrations/hue_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/hue_ble/hue_ble.classes.client.ts create mode 100644 ts/integrations/hue_ble/hue_ble.classes.configflow.ts create mode 100644 ts/integrations/hue_ble/hue_ble.discovery.ts create mode 100644 ts/integrations/hue_ble/hue_ble.mapper.ts delete mode 100644 ts/integrations/husqvarna_automower_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/husqvarna_automower_ble/husqvarna_automower_ble.classes.client.ts create mode 100644 ts/integrations/husqvarna_automower_ble/husqvarna_automower_ble.classes.configflow.ts create mode 100644 ts/integrations/husqvarna_automower_ble/husqvarna_automower_ble.discovery.ts create mode 100644 ts/integrations/husqvarna_automower_ble/husqvarna_automower_ble.mapper.ts delete mode 100644 ts/integrations/ialarm/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ialarm/ialarm.classes.client.ts create mode 100644 ts/integrations/ialarm/ialarm.classes.configflow.ts create mode 100644 ts/integrations/ialarm/ialarm.discovery.ts create mode 100644 ts/integrations/ialarm/ialarm.mapper.ts delete mode 100644 ts/integrations/iammeter/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iammeter/iammeter.classes.client.ts create mode 100644 ts/integrations/iammeter/iammeter.classes.configflow.ts create mode 100644 ts/integrations/iammeter/iammeter.discovery.ts create mode 100644 ts/integrations/iammeter/iammeter.mapper.ts delete mode 100644 ts/integrations/ibeacon/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ibeacon/ibeacon.classes.client.ts create mode 100644 ts/integrations/ibeacon/ibeacon.classes.configflow.ts create mode 100644 ts/integrations/ibeacon/ibeacon.discovery.ts create mode 100644 ts/integrations/ibeacon/ibeacon.mapper.ts delete mode 100644 ts/integrations/idasen_desk/.generated-by-smarthome-exchange create mode 100644 ts/integrations/idasen_desk/idasen_desk.classes.client.ts create mode 100644 ts/integrations/idasen_desk/idasen_desk.classes.configflow.ts create mode 100644 ts/integrations/idasen_desk/idasen_desk.discovery.ts create mode 100644 ts/integrations/idasen_desk/idasen_desk.mapper.ts delete mode 100644 ts/integrations/idteck_prox/.generated-by-smarthome-exchange create mode 100644 ts/integrations/idteck_prox/idteck_prox.classes.client.ts create mode 100644 ts/integrations/idteck_prox/idteck_prox.classes.configflow.ts create mode 100644 ts/integrations/idteck_prox/idteck_prox.discovery.ts create mode 100644 ts/integrations/idteck_prox/idteck_prox.mapper.ts delete mode 100644 ts/integrations/iglo/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iglo/iglo.classes.client.ts create mode 100644 ts/integrations/iglo/iglo.classes.configflow.ts create mode 100644 ts/integrations/iglo/iglo.discovery.ts create mode 100644 ts/integrations/iglo/iglo.mapper.ts delete mode 100644 ts/integrations/ihc/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ihc/ihc.classes.client.ts create mode 100644 ts/integrations/ihc/ihc.classes.configflow.ts create mode 100644 ts/integrations/ihc/ihc.discovery.ts create mode 100644 ts/integrations/ihc/ihc.mapper.ts delete mode 100644 ts/integrations/imeon_inverter/.generated-by-smarthome-exchange create mode 100644 ts/integrations/imeon_inverter/imeon_inverter.classes.client.ts create mode 100644 ts/integrations/imeon_inverter/imeon_inverter.classes.configflow.ts create mode 100644 ts/integrations/imeon_inverter/imeon_inverter.discovery.ts create mode 100644 ts/integrations/imeon_inverter/imeon_inverter.mapper.ts delete mode 100644 ts/integrations/immich/.generated-by-smarthome-exchange create mode 100644 ts/integrations/immich/immich.classes.client.ts create mode 100644 ts/integrations/immich/immich.classes.configflow.ts create mode 100644 ts/integrations/immich/immich.discovery.ts create mode 100644 ts/integrations/immich/immich.mapper.ts delete mode 100644 ts/integrations/improv_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/improv_ble/improv_ble.classes.client.ts create mode 100644 ts/integrations/improv_ble/improv_ble.classes.configflow.ts create mode 100644 ts/integrations/improv_ble/improv_ble.discovery.ts create mode 100644 ts/integrations/improv_ble/improv_ble.mapper.ts delete mode 100644 ts/integrations/incomfort/.generated-by-smarthome-exchange create mode 100644 ts/integrations/incomfort/incomfort.classes.client.ts create mode 100644 ts/integrations/incomfort/incomfort.classes.configflow.ts create mode 100644 ts/integrations/incomfort/incomfort.discovery.ts create mode 100644 ts/integrations/incomfort/incomfort.mapper.ts delete mode 100644 ts/integrations/indevolt/.generated-by-smarthome-exchange create mode 100644 ts/integrations/indevolt/indevolt.classes.client.ts create mode 100644 ts/integrations/indevolt/indevolt.classes.configflow.ts create mode 100644 ts/integrations/indevolt/indevolt.discovery.ts create mode 100644 ts/integrations/indevolt/indevolt.mapper.ts delete mode 100644 ts/integrations/inels/.generated-by-smarthome-exchange create mode 100644 ts/integrations/inels/inels.classes.client.ts create mode 100644 ts/integrations/inels/inels.classes.configflow.ts create mode 100644 ts/integrations/inels/inels.discovery.ts create mode 100644 ts/integrations/inels/inels.mapper.ts delete mode 100644 ts/integrations/influxdb/.generated-by-smarthome-exchange create mode 100644 ts/integrations/influxdb/influxdb.classes.client.ts create mode 100644 ts/integrations/influxdb/influxdb.classes.configflow.ts create mode 100644 ts/integrations/influxdb/influxdb.discovery.ts create mode 100644 ts/integrations/influxdb/influxdb.mapper.ts delete mode 100644 ts/integrations/inkbird/.generated-by-smarthome-exchange create mode 100644 ts/integrations/inkbird/inkbird.classes.client.ts create mode 100644 ts/integrations/inkbird/inkbird.classes.configflow.ts create mode 100644 ts/integrations/inkbird/inkbird.discovery.ts create mode 100644 ts/integrations/inkbird/inkbird.mapper.ts delete mode 100644 ts/integrations/insteon/.generated-by-smarthome-exchange create mode 100644 ts/integrations/insteon/insteon.classes.client.ts create mode 100644 ts/integrations/insteon/insteon.classes.configflow.ts create mode 100644 ts/integrations/insteon/insteon.discovery.ts create mode 100644 ts/integrations/insteon/insteon.mapper.ts delete mode 100644 ts/integrations/intellifire/.generated-by-smarthome-exchange create mode 100644 ts/integrations/intellifire/intellifire.classes.client.ts create mode 100644 ts/integrations/intellifire/intellifire.classes.configflow.ts create mode 100644 ts/integrations/intellifire/intellifire.discovery.ts create mode 100644 ts/integrations/intellifire/intellifire.mapper.ts delete mode 100644 ts/integrations/iometer/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iometer/iometer.classes.client.ts create mode 100644 ts/integrations/iometer/iometer.classes.configflow.ts create mode 100644 ts/integrations/iometer/iometer.discovery.ts create mode 100644 ts/integrations/iometer/iometer.mapper.ts delete mode 100644 ts/integrations/iotawatt/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iotawatt/iotawatt.classes.client.ts create mode 100644 ts/integrations/iotawatt/iotawatt.classes.configflow.ts create mode 100644 ts/integrations/iotawatt/iotawatt.discovery.ts create mode 100644 ts/integrations/iotawatt/iotawatt.mapper.ts delete mode 100644 ts/integrations/iperf3/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iperf3/iperf3.classes.client.ts create mode 100644 ts/integrations/iperf3/iperf3.classes.configflow.ts create mode 100644 ts/integrations/iperf3/iperf3.discovery.ts create mode 100644 ts/integrations/iperf3/iperf3.mapper.ts delete mode 100644 ts/integrations/iron_os/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iron_os/iron_os.classes.client.ts create mode 100644 ts/integrations/iron_os/iron_os.classes.configflow.ts create mode 100644 ts/integrations/iron_os/iron_os.discovery.ts create mode 100644 ts/integrations/iron_os/iron_os.mapper.ts delete mode 100644 ts/integrations/iskra/.generated-by-smarthome-exchange create mode 100644 ts/integrations/iskra/iskra.classes.client.ts create mode 100644 ts/integrations/iskra/iskra.classes.configflow.ts create mode 100644 ts/integrations/iskra/iskra.discovery.ts create mode 100644 ts/integrations/iskra/iskra.mapper.ts delete mode 100644 ts/integrations/isy994/.generated-by-smarthome-exchange create mode 100644 ts/integrations/isy994/isy994.classes.client.ts create mode 100644 ts/integrations/isy994/isy994.classes.configflow.ts create mode 100644 ts/integrations/isy994/isy994.discovery.ts create mode 100644 ts/integrations/isy994/isy994.mapper.ts delete mode 100644 ts/integrations/itunes/.generated-by-smarthome-exchange create mode 100644 ts/integrations/itunes/itunes.classes.client.ts create mode 100644 ts/integrations/itunes/itunes.classes.configflow.ts create mode 100644 ts/integrations/itunes/itunes.discovery.ts create mode 100644 ts/integrations/itunes/itunes.mapper.ts delete mode 100644 ts/integrations/izone/.generated-by-smarthome-exchange create mode 100644 ts/integrations/izone/izone.classes.client.ts create mode 100644 ts/integrations/izone/izone.classes.configflow.ts create mode 100644 ts/integrations/izone/izone.discovery.ts create mode 100644 ts/integrations/izone/izone.mapper.ts delete mode 100644 ts/integrations/jvc_projector/.generated-by-smarthome-exchange create mode 100644 ts/integrations/jvc_projector/jvc_projector.classes.client.ts create mode 100644 ts/integrations/jvc_projector/jvc_projector.classes.configflow.ts create mode 100644 ts/integrations/jvc_projector/jvc_projector.discovery.ts create mode 100644 ts/integrations/jvc_projector/jvc_projector.mapper.ts delete mode 100644 ts/integrations/kaleidescape/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kaleidescape/kaleidescape.classes.client.ts create mode 100644 ts/integrations/kaleidescape/kaleidescape.classes.configflow.ts create mode 100644 ts/integrations/kaleidescape/kaleidescape.discovery.ts create mode 100644 ts/integrations/kaleidescape/kaleidescape.mapper.ts delete mode 100644 ts/integrations/kankun/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kankun/kankun.classes.client.ts create mode 100644 ts/integrations/kankun/kankun.classes.configflow.ts create mode 100644 ts/integrations/kankun/kankun.discovery.ts create mode 100644 ts/integrations/kankun/kankun.mapper.ts delete mode 100644 ts/integrations/keba/.generated-by-smarthome-exchange create mode 100644 ts/integrations/keba/keba.classes.client.ts create mode 100644 ts/integrations/keba/keba.classes.configflow.ts create mode 100644 ts/integrations/keba/keba.discovery.ts create mode 100644 ts/integrations/keba/keba.mapper.ts delete mode 100644 ts/integrations/keenetic_ndms2/.generated-by-smarthome-exchange create mode 100644 ts/integrations/keenetic_ndms2/keenetic_ndms2.classes.client.ts create mode 100644 ts/integrations/keenetic_ndms2/keenetic_ndms2.classes.configflow.ts create mode 100644 ts/integrations/keenetic_ndms2/keenetic_ndms2.discovery.ts create mode 100644 ts/integrations/keenetic_ndms2/keenetic_ndms2.mapper.ts delete mode 100644 ts/integrations/kef/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kef/kef.classes.client.ts create mode 100644 ts/integrations/kef/kef.classes.configflow.ts create mode 100644 ts/integrations/kef/kef.discovery.ts create mode 100644 ts/integrations/kef/kef.mapper.ts delete mode 100644 ts/integrations/kegtron/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kegtron/kegtron.classes.client.ts create mode 100644 ts/integrations/kegtron/kegtron.classes.configflow.ts create mode 100644 ts/integrations/kegtron/kegtron.discovery.ts create mode 100644 ts/integrations/kegtron/kegtron.mapper.ts delete mode 100644 ts/integrations/keyboard_remote/.generated-by-smarthome-exchange create mode 100644 ts/integrations/keyboard_remote/keyboard_remote.classes.client.ts create mode 100644 ts/integrations/keyboard_remote/keyboard_remote.classes.configflow.ts create mode 100644 ts/integrations/keyboard_remote/keyboard_remote.discovery.ts create mode 100644 ts/integrations/keyboard_remote/keyboard_remote.mapper.ts delete mode 100644 ts/integrations/kiosker/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kiosker/kiosker.classes.client.ts create mode 100644 ts/integrations/kiosker/kiosker.classes.configflow.ts create mode 100644 ts/integrations/kiosker/kiosker.discovery.ts create mode 100644 ts/integrations/kiosker/kiosker.mapper.ts delete mode 100644 ts/integrations/kira/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kira/kira.classes.client.ts create mode 100644 ts/integrations/kira/kira.classes.configflow.ts create mode 100644 ts/integrations/kira/kira.discovery.ts create mode 100644 ts/integrations/kira/kira.mapper.ts delete mode 100644 ts/integrations/kmtronic/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kmtronic/kmtronic.classes.client.ts create mode 100644 ts/integrations/kmtronic/kmtronic.classes.configflow.ts create mode 100644 ts/integrations/kmtronic/kmtronic.discovery.ts create mode 100644 ts/integrations/kmtronic/kmtronic.mapper.ts delete mode 100644 ts/integrations/konnected/.generated-by-smarthome-exchange create mode 100644 ts/integrations/konnected/konnected.classes.client.ts create mode 100644 ts/integrations/konnected/konnected.classes.configflow.ts create mode 100644 ts/integrations/konnected/konnected.discovery.ts create mode 100644 ts/integrations/konnected/konnected.mapper.ts delete mode 100644 ts/integrations/kostal_plenticore/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kostal_plenticore/kostal_plenticore.classes.client.ts create mode 100644 ts/integrations/kostal_plenticore/kostal_plenticore.classes.configflow.ts create mode 100644 ts/integrations/kostal_plenticore/kostal_plenticore.discovery.ts create mode 100644 ts/integrations/kostal_plenticore/kostal_plenticore.mapper.ts delete mode 100644 ts/integrations/kulersky/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kulersky/kulersky.classes.client.ts create mode 100644 ts/integrations/kulersky/kulersky.classes.configflow.ts create mode 100644 ts/integrations/kulersky/kulersky.discovery.ts create mode 100644 ts/integrations/kulersky/kulersky.mapper.ts delete mode 100644 ts/integrations/kwb/.generated-by-smarthome-exchange create mode 100644 ts/integrations/kwb/kwb.classes.client.ts create mode 100644 ts/integrations/kwb/kwb.classes.configflow.ts create mode 100644 ts/integrations/kwb/kwb.discovery.ts create mode 100644 ts/integrations/kwb/kwb.mapper.ts delete mode 100644 ts/integrations/lacrosse/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lacrosse/lacrosse.classes.client.ts create mode 100644 ts/integrations/lacrosse/lacrosse.classes.configflow.ts create mode 100644 ts/integrations/lacrosse/lacrosse.discovery.ts create mode 100644 ts/integrations/lacrosse/lacrosse.mapper.ts delete mode 100644 ts/integrations/lametric/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lametric/lametric.classes.client.ts create mode 100644 ts/integrations/lametric/lametric.classes.configflow.ts create mode 100644 ts/integrations/lametric/lametric.discovery.ts create mode 100644 ts/integrations/lametric/lametric.mapper.ts delete mode 100644 ts/integrations/landisgyr_heat_meter/.generated-by-smarthome-exchange create mode 100644 ts/integrations/landisgyr_heat_meter/landisgyr_heat_meter.classes.client.ts create mode 100644 ts/integrations/landisgyr_heat_meter/landisgyr_heat_meter.classes.configflow.ts create mode 100644 ts/integrations/landisgyr_heat_meter/landisgyr_heat_meter.discovery.ts create mode 100644 ts/integrations/landisgyr_heat_meter/landisgyr_heat_meter.mapper.ts delete mode 100644 ts/integrations/lannouncer/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lannouncer/lannouncer.classes.client.ts create mode 100644 ts/integrations/lannouncer/lannouncer.classes.configflow.ts create mode 100644 ts/integrations/lannouncer/lannouncer.discovery.ts create mode 100644 ts/integrations/lannouncer/lannouncer.mapper.ts delete mode 100644 ts/integrations/lcn/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lcn/lcn.classes.client.ts create mode 100644 ts/integrations/lcn/lcn.classes.configflow.ts create mode 100644 ts/integrations/lcn/lcn.discovery.ts create mode 100644 ts/integrations/lcn/lcn.mapper.ts delete mode 100644 ts/integrations/ld2410_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ld2410_ble/ld2410_ble.classes.client.ts create mode 100644 ts/integrations/ld2410_ble/ld2410_ble.classes.configflow.ts create mode 100644 ts/integrations/ld2410_ble/ld2410_ble.discovery.ts create mode 100644 ts/integrations/ld2410_ble/ld2410_ble.mapper.ts delete mode 100644 ts/integrations/leaone/.generated-by-smarthome-exchange create mode 100644 ts/integrations/leaone/leaone.classes.client.ts create mode 100644 ts/integrations/leaone/leaone.classes.configflow.ts create mode 100644 ts/integrations/leaone/leaone.discovery.ts create mode 100644 ts/integrations/leaone/leaone.mapper.ts delete mode 100644 ts/integrations/led_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/led_ble/led_ble.classes.client.ts create mode 100644 ts/integrations/led_ble/led_ble.classes.configflow.ts create mode 100644 ts/integrations/led_ble/led_ble.discovery.ts create mode 100644 ts/integrations/led_ble/led_ble.mapper.ts delete mode 100644 ts/integrations/lektrico/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lektrico/lektrico.classes.client.ts create mode 100644 ts/integrations/lektrico/lektrico.classes.configflow.ts create mode 100644 ts/integrations/lektrico/lektrico.discovery.ts create mode 100644 ts/integrations/lektrico/lektrico.mapper.ts delete mode 100644 ts/integrations/lg_netcast/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lg_netcast/lg_netcast.classes.client.ts create mode 100644 ts/integrations/lg_netcast/lg_netcast.classes.configflow.ts create mode 100644 ts/integrations/lg_netcast/lg_netcast.discovery.ts create mode 100644 ts/integrations/lg_netcast/lg_netcast.mapper.ts delete mode 100644 ts/integrations/lg_soundbar/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lg_soundbar/lg_soundbar.classes.client.ts create mode 100644 ts/integrations/lg_soundbar/lg_soundbar.classes.configflow.ts create mode 100644 ts/integrations/lg_soundbar/lg_soundbar.discovery.ts create mode 100644 ts/integrations/lg_soundbar/lg_soundbar.mapper.ts delete mode 100644 ts/integrations/libre_hardware_monitor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/libre_hardware_monitor/libre_hardware_monitor.classes.client.ts create mode 100644 ts/integrations/libre_hardware_monitor/libre_hardware_monitor.classes.configflow.ts create mode 100644 ts/integrations/libre_hardware_monitor/libre_hardware_monitor.discovery.ts create mode 100644 ts/integrations/libre_hardware_monitor/libre_hardware_monitor.mapper.ts delete mode 100644 ts/integrations/lidarr/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lidarr/lidarr.classes.client.ts create mode 100644 ts/integrations/lidarr/lidarr.classes.configflow.ts create mode 100644 ts/integrations/lidarr/lidarr.discovery.ts create mode 100644 ts/integrations/lidarr/lidarr.mapper.ts delete mode 100644 ts/integrations/lifx/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lifx/lifx.classes.client.ts create mode 100644 ts/integrations/lifx/lifx.classes.configflow.ts create mode 100644 ts/integrations/lifx/lifx.discovery.ts create mode 100644 ts/integrations/lifx/lifx.mapper.ts delete mode 100644 ts/integrations/linksys_smart/.generated-by-smarthome-exchange create mode 100644 ts/integrations/linksys_smart/linksys_smart.classes.client.ts create mode 100644 ts/integrations/linksys_smart/linksys_smart.classes.configflow.ts create mode 100644 ts/integrations/linksys_smart/linksys_smart.discovery.ts create mode 100644 ts/integrations/linksys_smart/linksys_smart.mapper.ts delete mode 100644 ts/integrations/linux_battery/.generated-by-smarthome-exchange create mode 100644 ts/integrations/linux_battery/linux_battery.classes.client.ts create mode 100644 ts/integrations/linux_battery/linux_battery.classes.configflow.ts create mode 100644 ts/integrations/linux_battery/linux_battery.discovery.ts create mode 100644 ts/integrations/linux_battery/linux_battery.mapper.ts delete mode 100644 ts/integrations/litejet/.generated-by-smarthome-exchange create mode 100644 ts/integrations/litejet/litejet.classes.client.ts create mode 100644 ts/integrations/litejet/litejet.classes.configflow.ts create mode 100644 ts/integrations/litejet/litejet.discovery.ts create mode 100644 ts/integrations/litejet/litejet.mapper.ts delete mode 100644 ts/integrations/livisi/.generated-by-smarthome-exchange create mode 100644 ts/integrations/livisi/livisi.classes.client.ts create mode 100644 ts/integrations/livisi/livisi.classes.configflow.ts create mode 100644 ts/integrations/livisi/livisi.discovery.ts create mode 100644 ts/integrations/livisi/livisi.mapper.ts delete mode 100644 ts/integrations/local_calendar/.generated-by-smarthome-exchange create mode 100644 ts/integrations/local_calendar/local_calendar.classes.client.ts create mode 100644 ts/integrations/local_calendar/local_calendar.classes.configflow.ts create mode 100644 ts/integrations/local_calendar/local_calendar.discovery.ts create mode 100644 ts/integrations/local_calendar/local_calendar.mapper.ts delete mode 100644 ts/integrations/local_file/.generated-by-smarthome-exchange create mode 100644 ts/integrations/local_file/local_file.classes.client.ts create mode 100644 ts/integrations/local_file/local_file.classes.configflow.ts create mode 100644 ts/integrations/local_file/local_file.discovery.ts create mode 100644 ts/integrations/local_file/local_file.mapper.ts delete mode 100644 ts/integrations/local_ip/.generated-by-smarthome-exchange create mode 100644 ts/integrations/local_ip/local_ip.classes.client.ts create mode 100644 ts/integrations/local_ip/local_ip.classes.configflow.ts create mode 100644 ts/integrations/local_ip/local_ip.discovery.ts create mode 100644 ts/integrations/local_ip/local_ip.mapper.ts delete mode 100644 ts/integrations/local_todo/.generated-by-smarthome-exchange create mode 100644 ts/integrations/local_todo/local_todo.classes.client.ts create mode 100644 ts/integrations/local_todo/local_todo.classes.configflow.ts create mode 100644 ts/integrations/local_todo/local_todo.discovery.ts create mode 100644 ts/integrations/local_todo/local_todo.mapper.ts delete mode 100644 ts/integrations/locative/.generated-by-smarthome-exchange create mode 100644 ts/integrations/locative/locative.classes.client.ts create mode 100644 ts/integrations/locative/locative.classes.configflow.ts create mode 100644 ts/integrations/locative/locative.discovery.ts create mode 100644 ts/integrations/locative/locative.mapper.ts delete mode 100644 ts/integrations/lookin/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lookin/lookin.classes.client.ts create mode 100644 ts/integrations/lookin/lookin.classes.configflow.ts create mode 100644 ts/integrations/lookin/lookin.discovery.ts create mode 100644 ts/integrations/lookin/lookin.mapper.ts delete mode 100644 ts/integrations/loqed/.generated-by-smarthome-exchange create mode 100644 ts/integrations/loqed/loqed.classes.client.ts create mode 100644 ts/integrations/loqed/loqed.classes.configflow.ts create mode 100644 ts/integrations/loqed/loqed.discovery.ts create mode 100644 ts/integrations/loqed/loqed.mapper.ts delete mode 100644 ts/integrations/luci/.generated-by-smarthome-exchange create mode 100644 ts/integrations/luci/luci.classes.client.ts create mode 100644 ts/integrations/luci/luci.classes.configflow.ts create mode 100644 ts/integrations/luci/luci.discovery.ts create mode 100644 ts/integrations/luci/luci.mapper.ts delete mode 100644 ts/integrations/lunatone/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lunatone/lunatone.classes.client.ts create mode 100644 ts/integrations/lunatone/lunatone.classes.configflow.ts create mode 100644 ts/integrations/lunatone/lunatone.discovery.ts create mode 100644 ts/integrations/lunatone/lunatone.mapper.ts delete mode 100644 ts/integrations/lupusec/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lupusec/lupusec.classes.client.ts create mode 100644 ts/integrations/lupusec/lupusec.classes.configflow.ts create mode 100644 ts/integrations/lupusec/lupusec.discovery.ts create mode 100644 ts/integrations/lupusec/lupusec.mapper.ts delete mode 100644 ts/integrations/lutron/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lutron/lutron.classes.client.ts create mode 100644 ts/integrations/lutron/lutron.classes.configflow.ts create mode 100644 ts/integrations/lutron/lutron.discovery.ts create mode 100644 ts/integrations/lutron/lutron.mapper.ts delete mode 100644 ts/integrations/lutron_caseta/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lutron_caseta/lutron_caseta.classes.client.ts create mode 100644 ts/integrations/lutron_caseta/lutron_caseta.classes.configflow.ts create mode 100644 ts/integrations/lutron_caseta/lutron_caseta.discovery.ts create mode 100644 ts/integrations/lutron_caseta/lutron_caseta.mapper.ts delete mode 100644 ts/integrations/lw12wifi/.generated-by-smarthome-exchange create mode 100644 ts/integrations/lw12wifi/lw12wifi.classes.client.ts create mode 100644 ts/integrations/lw12wifi/lw12wifi.classes.configflow.ts create mode 100644 ts/integrations/lw12wifi/lw12wifi.discovery.ts create mode 100644 ts/integrations/lw12wifi/lw12wifi.mapper.ts delete mode 100644 ts/integrations/manual_mqtt/.generated-by-smarthome-exchange create mode 100644 ts/integrations/manual_mqtt/manual_mqtt.classes.client.ts create mode 100644 ts/integrations/manual_mqtt/manual_mqtt.classes.configflow.ts create mode 100644 ts/integrations/manual_mqtt/manual_mqtt.discovery.ts create mode 100644 ts/integrations/manual_mqtt/manual_mqtt.mapper.ts delete mode 100644 ts/integrations/marytts/.generated-by-smarthome-exchange create mode 100644 ts/integrations/marytts/marytts.classes.client.ts create mode 100644 ts/integrations/marytts/marytts.classes.configflow.ts create mode 100644 ts/integrations/marytts/marytts.discovery.ts create mode 100644 ts/integrations/marytts/marytts.mapper.ts delete mode 100644 ts/integrations/maxcube/.generated-by-smarthome-exchange create mode 100644 ts/integrations/maxcube/maxcube.classes.client.ts create mode 100644 ts/integrations/maxcube/maxcube.classes.configflow.ts create mode 100644 ts/integrations/maxcube/maxcube.discovery.ts create mode 100644 ts/integrations/maxcube/maxcube.mapper.ts delete mode 100644 ts/integrations/mcp/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mcp/mcp.classes.client.ts create mode 100644 ts/integrations/mcp/mcp.classes.configflow.ts create mode 100644 ts/integrations/mcp/mcp.discovery.ts create mode 100644 ts/integrations/mcp/mcp.mapper.ts delete mode 100644 ts/integrations/mcp_server/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mcp_server/mcp_server.classes.client.ts create mode 100644 ts/integrations/mcp_server/mcp_server.classes.configflow.ts create mode 100644 ts/integrations/mcp_server/mcp_server.discovery.ts create mode 100644 ts/integrations/mcp_server/mcp_server.mapper.ts delete mode 100644 ts/integrations/mealie/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mealie/mealie.classes.client.ts create mode 100644 ts/integrations/mealie/mealie.classes.configflow.ts create mode 100644 ts/integrations/mealie/mealie.discovery.ts create mode 100644 ts/integrations/mealie/mealie.mapper.ts delete mode 100644 ts/integrations/medcom_ble/.generated-by-smarthome-exchange create mode 100644 ts/integrations/medcom_ble/medcom_ble.classes.client.ts create mode 100644 ts/integrations/medcom_ble/medcom_ble.classes.configflow.ts create mode 100644 ts/integrations/medcom_ble/medcom_ble.discovery.ts create mode 100644 ts/integrations/medcom_ble/medcom_ble.mapper.ts delete mode 100644 ts/integrations/mediaroom/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mediaroom/mediaroom.classes.client.ts create mode 100644 ts/integrations/mediaroom/mediaroom.classes.configflow.ts create mode 100644 ts/integrations/mediaroom/mediaroom.discovery.ts create mode 100644 ts/integrations/mediaroom/mediaroom.mapper.ts delete mode 100644 ts/integrations/melnor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/melnor/melnor.classes.client.ts create mode 100644 ts/integrations/melnor/melnor.classes.configflow.ts create mode 100644 ts/integrations/melnor/melnor.discovery.ts create mode 100644 ts/integrations/melnor/melnor.mapper.ts delete mode 100644 ts/integrations/mfi/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mfi/mfi.classes.client.ts create mode 100644 ts/integrations/mfi/mfi.classes.configflow.ts create mode 100644 ts/integrations/mfi/mfi.discovery.ts create mode 100644 ts/integrations/mfi/mfi.mapper.ts delete mode 100644 ts/integrations/mill/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mill/mill.classes.client.ts create mode 100644 ts/integrations/mill/mill.classes.configflow.ts create mode 100644 ts/integrations/mill/mill.discovery.ts create mode 100644 ts/integrations/mill/mill.mapper.ts delete mode 100644 ts/integrations/minecraft_server/.generated-by-smarthome-exchange create mode 100644 ts/integrations/minecraft_server/minecraft_server.classes.client.ts create mode 100644 ts/integrations/minecraft_server/minecraft_server.classes.configflow.ts create mode 100644 ts/integrations/minecraft_server/minecraft_server.discovery.ts create mode 100644 ts/integrations/minecraft_server/minecraft_server.mapper.ts delete mode 100644 ts/integrations/mjpeg/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mjpeg/mjpeg.classes.client.ts create mode 100644 ts/integrations/mjpeg/mjpeg.classes.configflow.ts create mode 100644 ts/integrations/mjpeg/mjpeg.discovery.ts create mode 100644 ts/integrations/mjpeg/mjpeg.mapper.ts delete mode 100644 ts/integrations/moat/.generated-by-smarthome-exchange create mode 100644 ts/integrations/moat/moat.classes.client.ts create mode 100644 ts/integrations/moat/moat.classes.configflow.ts create mode 100644 ts/integrations/moat/moat.discovery.ts create mode 100644 ts/integrations/moat/moat.mapper.ts delete mode 100644 ts/integrations/mobile_app/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mobile_app/mobile_app.classes.client.ts create mode 100644 ts/integrations/mobile_app/mobile_app.classes.configflow.ts create mode 100644 ts/integrations/mobile_app/mobile_app.discovery.ts create mode 100644 ts/integrations/mobile_app/mobile_app.mapper.ts delete mode 100644 ts/integrations/mochad/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mochad/mochad.classes.client.ts create mode 100644 ts/integrations/mochad/mochad.classes.configflow.ts create mode 100644 ts/integrations/mochad/mochad.discovery.ts create mode 100644 ts/integrations/mochad/mochad.mapper.ts delete mode 100644 ts/integrations/modem_callerid/.generated-by-smarthome-exchange create mode 100644 ts/integrations/modem_callerid/modem_callerid.classes.client.ts create mode 100644 ts/integrations/modem_callerid/modem_callerid.classes.configflow.ts create mode 100644 ts/integrations/modem_callerid/modem_callerid.discovery.ts create mode 100644 ts/integrations/modem_callerid/modem_callerid.mapper.ts delete mode 100644 ts/integrations/modern_forms/.generated-by-smarthome-exchange create mode 100644 ts/integrations/modern_forms/modern_forms.classes.client.ts create mode 100644 ts/integrations/modern_forms/modern_forms.classes.configflow.ts create mode 100644 ts/integrations/modern_forms/modern_forms.discovery.ts create mode 100644 ts/integrations/modern_forms/modern_forms.mapper.ts delete mode 100644 ts/integrations/moehlenhoff_alpha2/.generated-by-smarthome-exchange create mode 100644 ts/integrations/moehlenhoff_alpha2/moehlenhoff_alpha2.classes.client.ts create mode 100644 ts/integrations/moehlenhoff_alpha2/moehlenhoff_alpha2.classes.configflow.ts create mode 100644 ts/integrations/moehlenhoff_alpha2/moehlenhoff_alpha2.discovery.ts create mode 100644 ts/integrations/moehlenhoff_alpha2/moehlenhoff_alpha2.mapper.ts delete mode 100644 ts/integrations/monoprice/.generated-by-smarthome-exchange create mode 100644 ts/integrations/monoprice/monoprice.classes.client.ts create mode 100644 ts/integrations/monoprice/monoprice.classes.configflow.ts create mode 100644 ts/integrations/monoprice/monoprice.discovery.ts create mode 100644 ts/integrations/monoprice/monoprice.mapper.ts delete mode 100644 ts/integrations/mopeka/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mopeka/mopeka.classes.client.ts create mode 100644 ts/integrations/mopeka/mopeka.classes.configflow.ts create mode 100644 ts/integrations/mopeka/mopeka.discovery.ts create mode 100644 ts/integrations/mopeka/mopeka.mapper.ts delete mode 100644 ts/integrations/motion_blinds/.generated-by-smarthome-exchange create mode 100644 ts/integrations/motion_blinds/motion_blinds.classes.client.ts create mode 100644 ts/integrations/motion_blinds/motion_blinds.classes.configflow.ts create mode 100644 ts/integrations/motion_blinds/motion_blinds.discovery.ts create mode 100644 ts/integrations/motion_blinds/motion_blinds.mapper.ts delete mode 100644 ts/integrations/motionmount/.generated-by-smarthome-exchange create mode 100644 ts/integrations/motionmount/motionmount.classes.client.ts create mode 100644 ts/integrations/motionmount/motionmount.classes.configflow.ts create mode 100644 ts/integrations/motionmount/motionmount.discovery.ts create mode 100644 ts/integrations/motionmount/motionmount.mapper.ts delete mode 100644 ts/integrations/mqtt_eventstream/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mqtt_eventstream/mqtt_eventstream.classes.client.ts create mode 100644 ts/integrations/mqtt_eventstream/mqtt_eventstream.classes.configflow.ts create mode 100644 ts/integrations/mqtt_eventstream/mqtt_eventstream.discovery.ts create mode 100644 ts/integrations/mqtt_eventstream/mqtt_eventstream.mapper.ts delete mode 100644 ts/integrations/mqtt_json/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mqtt_json/mqtt_json.classes.client.ts create mode 100644 ts/integrations/mqtt_json/mqtt_json.classes.configflow.ts create mode 100644 ts/integrations/mqtt_json/mqtt_json.discovery.ts create mode 100644 ts/integrations/mqtt_json/mqtt_json.mapper.ts delete mode 100644 ts/integrations/mqtt_room/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mqtt_room/mqtt_room.classes.client.ts create mode 100644 ts/integrations/mqtt_room/mqtt_room.classes.configflow.ts create mode 100644 ts/integrations/mqtt_room/mqtt_room.discovery.ts create mode 100644 ts/integrations/mqtt_room/mqtt_room.mapper.ts delete mode 100644 ts/integrations/mqtt_statestream/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mqtt_statestream/mqtt_statestream.classes.client.ts create mode 100644 ts/integrations/mqtt_statestream/mqtt_statestream.classes.configflow.ts create mode 100644 ts/integrations/mqtt_statestream/mqtt_statestream.discovery.ts create mode 100644 ts/integrations/mqtt_statestream/mqtt_statestream.mapper.ts delete mode 100644 ts/integrations/music_assistant/.generated-by-smarthome-exchange create mode 100644 ts/integrations/music_assistant/music_assistant.classes.client.ts create mode 100644 ts/integrations/music_assistant/music_assistant.classes.configflow.ts create mode 100644 ts/integrations/music_assistant/music_assistant.discovery.ts create mode 100644 ts/integrations/music_assistant/music_assistant.mapper.ts delete mode 100644 ts/integrations/mutesync/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mutesync/mutesync.classes.client.ts create mode 100644 ts/integrations/mutesync/mutesync.classes.configflow.ts create mode 100644 ts/integrations/mutesync/mutesync.discovery.ts create mode 100644 ts/integrations/mutesync/mutesync.mapper.ts delete mode 100644 ts/integrations/mycroft/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mycroft/mycroft.classes.client.ts create mode 100644 ts/integrations/mycroft/mycroft.classes.configflow.ts create mode 100644 ts/integrations/mycroft/mycroft.discovery.ts create mode 100644 ts/integrations/mycroft/mycroft.mapper.ts delete mode 100644 ts/integrations/mysensors/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mysensors/mysensors.classes.client.ts create mode 100644 ts/integrations/mysensors/mysensors.classes.configflow.ts create mode 100644 ts/integrations/mysensors/mysensors.discovery.ts create mode 100644 ts/integrations/mysensors/mysensors.mapper.ts delete mode 100644 ts/integrations/mystrom/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mystrom/mystrom.classes.client.ts create mode 100644 ts/integrations/mystrom/mystrom.classes.configflow.ts create mode 100644 ts/integrations/mystrom/mystrom.discovery.ts create mode 100644 ts/integrations/mystrom/mystrom.mapper.ts delete mode 100644 ts/integrations/nad/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nad/nad.classes.client.ts create mode 100644 ts/integrations/nad/nad.classes.configflow.ts create mode 100644 ts/integrations/nad/nad.discovery.ts create mode 100644 ts/integrations/nad/nad.mapper.ts delete mode 100644 ts/integrations/nam/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nam/nam.classes.client.ts create mode 100644 ts/integrations/nam/nam.classes.configflow.ts create mode 100644 ts/integrations/nam/nam.discovery.ts create mode 100644 ts/integrations/nam/nam.mapper.ts delete mode 100644 ts/integrations/nasweb/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nasweb/nasweb.classes.client.ts create mode 100644 ts/integrations/nasweb/nasweb.classes.configflow.ts create mode 100644 ts/integrations/nasweb/nasweb.discovery.ts create mode 100644 ts/integrations/nasweb/nasweb.mapper.ts delete mode 100644 ts/integrations/ness_alarm/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ness_alarm/ness_alarm.classes.client.ts create mode 100644 ts/integrations/ness_alarm/ness_alarm.classes.configflow.ts create mode 100644 ts/integrations/ness_alarm/ness_alarm.discovery.ts create mode 100644 ts/integrations/ness_alarm/ness_alarm.mapper.ts delete mode 100644 ts/integrations/netdata/.generated-by-smarthome-exchange create mode 100644 ts/integrations/netdata/netdata.classes.client.ts create mode 100644 ts/integrations/netdata/netdata.classes.configflow.ts create mode 100644 ts/integrations/netdata/netdata.discovery.ts create mode 100644 ts/integrations/netdata/netdata.mapper.ts delete mode 100644 ts/integrations/netgear/.generated-by-smarthome-exchange create mode 100644 ts/integrations/netgear/netgear.classes.client.ts create mode 100644 ts/integrations/netgear/netgear.classes.configflow.ts create mode 100644 ts/integrations/netgear/netgear.discovery.ts create mode 100644 ts/integrations/netgear/netgear.mapper.ts delete mode 100644 ts/integrations/netgear_lte/.generated-by-smarthome-exchange create mode 100644 ts/integrations/netgear_lte/netgear_lte.classes.client.ts create mode 100644 ts/integrations/netgear_lte/netgear_lte.classes.configflow.ts create mode 100644 ts/integrations/netgear_lte/netgear_lte.discovery.ts create mode 100644 ts/integrations/netgear_lte/netgear_lte.mapper.ts delete mode 100644 ts/integrations/netio/.generated-by-smarthome-exchange create mode 100644 ts/integrations/netio/netio.classes.client.ts create mode 100644 ts/integrations/netio/netio.classes.configflow.ts create mode 100644 ts/integrations/netio/netio.discovery.ts create mode 100644 ts/integrations/netio/netio.mapper.ts delete mode 100644 ts/integrations/nfandroidtv/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nfandroidtv/nfandroidtv.classes.client.ts create mode 100644 ts/integrations/nfandroidtv/nfandroidtv.classes.configflow.ts create mode 100644 ts/integrations/nfandroidtv/nfandroidtv.discovery.ts create mode 100644 ts/integrations/nfandroidtv/nfandroidtv.mapper.ts delete mode 100644 ts/integrations/nibe_heatpump/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nibe_heatpump/nibe_heatpump.classes.client.ts create mode 100644 ts/integrations/nibe_heatpump/nibe_heatpump.classes.configflow.ts create mode 100644 ts/integrations/nibe_heatpump/nibe_heatpump.discovery.ts create mode 100644 ts/integrations/nibe_heatpump/nibe_heatpump.mapper.ts delete mode 100644 ts/integrations/niko_home_control/.generated-by-smarthome-exchange create mode 100644 ts/integrations/niko_home_control/niko_home_control.classes.client.ts create mode 100644 ts/integrations/niko_home_control/niko_home_control.classes.configflow.ts create mode 100644 ts/integrations/niko_home_control/niko_home_control.discovery.ts create mode 100644 ts/integrations/niko_home_control/niko_home_control.mapper.ts delete mode 100644 ts/integrations/nmap_tracker/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nmap_tracker/nmap_tracker.classes.client.ts create mode 100644 ts/integrations/nmap_tracker/nmap_tracker.classes.configflow.ts create mode 100644 ts/integrations/nmap_tracker/nmap_tracker.discovery.ts create mode 100644 ts/integrations/nmap_tracker/nmap_tracker.mapper.ts delete mode 100644 ts/integrations/nobo_hub/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nobo_hub/nobo_hub.classes.client.ts create mode 100644 ts/integrations/nobo_hub/nobo_hub.classes.configflow.ts create mode 100644 ts/integrations/nobo_hub/nobo_hub.discovery.ts create mode 100644 ts/integrations/nobo_hub/nobo_hub.mapper.ts delete mode 100644 ts/integrations/nrgkick/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nrgkick/nrgkick.classes.client.ts create mode 100644 ts/integrations/nrgkick/nrgkick.classes.configflow.ts create mode 100644 ts/integrations/nrgkick/nrgkick.discovery.ts create mode 100644 ts/integrations/nrgkick/nrgkick.mapper.ts delete mode 100644 ts/integrations/nuki/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nuki/nuki.classes.client.ts create mode 100644 ts/integrations/nuki/nuki.classes.configflow.ts create mode 100644 ts/integrations/nuki/nuki.discovery.ts create mode 100644 ts/integrations/nuki/nuki.mapper.ts delete mode 100644 ts/integrations/numato/.generated-by-smarthome-exchange create mode 100644 ts/integrations/numato/numato.classes.client.ts create mode 100644 ts/integrations/numato/numato.classes.configflow.ts create mode 100644 ts/integrations/numato/numato.discovery.ts create mode 100644 ts/integrations/numato/numato.mapper.ts delete mode 100644 ts/integrations/nut/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nut/nut.classes.client.ts create mode 100644 ts/integrations/nut/nut.classes.configflow.ts create mode 100644 ts/integrations/nut/nut.discovery.ts create mode 100644 ts/integrations/nut/nut.mapper.ts delete mode 100644 ts/integrations/nx584/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nx584/nx584.classes.client.ts create mode 100644 ts/integrations/nx584/nx584.classes.configflow.ts create mode 100644 ts/integrations/nx584/nx584.discovery.ts create mode 100644 ts/integrations/nx584/nx584.mapper.ts delete mode 100644 ts/integrations/nzbget/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nzbget/nzbget.classes.client.ts create mode 100644 ts/integrations/nzbget/nzbget.classes.configflow.ts create mode 100644 ts/integrations/nzbget/nzbget.discovery.ts create mode 100644 ts/integrations/nzbget/nzbget.mapper.ts delete mode 100644 ts/integrations/obihai/.generated-by-smarthome-exchange create mode 100644 ts/integrations/obihai/obihai.classes.client.ts create mode 100644 ts/integrations/obihai/obihai.classes.configflow.ts create mode 100644 ts/integrations/obihai/obihai.discovery.ts create mode 100644 ts/integrations/obihai/obihai.mapper.ts delete mode 100644 ts/integrations/octoprint/.generated-by-smarthome-exchange create mode 100644 ts/integrations/octoprint/octoprint.classes.client.ts create mode 100644 ts/integrations/octoprint/octoprint.classes.configflow.ts create mode 100644 ts/integrations/octoprint/octoprint.discovery.ts create mode 100644 ts/integrations/octoprint/octoprint.mapper.ts delete mode 100644 ts/integrations/oem/.generated-by-smarthome-exchange create mode 100644 ts/integrations/oem/oem.classes.client.ts create mode 100644 ts/integrations/oem/oem.classes.configflow.ts create mode 100644 ts/integrations/oem/oem.discovery.ts create mode 100644 ts/integrations/oem/oem.mapper.ts delete mode 100644 ts/integrations/ollama/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ollama/ollama.classes.client.ts create mode 100644 ts/integrations/ollama/ollama.classes.configflow.ts create mode 100644 ts/integrations/ollama/ollama.discovery.ts create mode 100644 ts/integrations/ollama/ollama.mapper.ts delete mode 100644 ts/integrations/ombi/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ombi/ombi.classes.client.ts create mode 100644 ts/integrations/ombi/ombi.classes.configflow.ts create mode 100644 ts/integrations/ombi/ombi.discovery.ts create mode 100644 ts/integrations/ombi/ombi.mapper.ts delete mode 100644 ts/integrations/onewire/.generated-by-smarthome-exchange create mode 100644 ts/integrations/onewire/onewire.classes.client.ts create mode 100644 ts/integrations/onewire/onewire.classes.configflow.ts create mode 100644 ts/integrations/onewire/onewire.discovery.ts create mode 100644 ts/integrations/onewire/onewire.mapper.ts delete mode 100644 ts/integrations/onkyo/.generated-by-smarthome-exchange create mode 100644 ts/integrations/onkyo/onkyo.classes.client.ts create mode 100644 ts/integrations/onkyo/onkyo.classes.configflow.ts create mode 100644 ts/integrations/onkyo/onkyo.discovery.ts create mode 100644 ts/integrations/onkyo/onkyo.mapper.ts delete mode 100644 ts/integrations/opendisplay/.generated-by-smarthome-exchange create mode 100644 ts/integrations/opendisplay/opendisplay.classes.client.ts create mode 100644 ts/integrations/opendisplay/opendisplay.classes.configflow.ts create mode 100644 ts/integrations/opendisplay/opendisplay.discovery.ts create mode 100644 ts/integrations/opendisplay/opendisplay.mapper.ts delete mode 100644 ts/integrations/openevse/.generated-by-smarthome-exchange create mode 100644 ts/integrations/openevse/openevse.classes.client.ts create mode 100644 ts/integrations/openevse/openevse.classes.configflow.ts create mode 100644 ts/integrations/openevse/openevse.discovery.ts create mode 100644 ts/integrations/openevse/openevse.mapper.ts delete mode 100644 ts/integrations/opengarage/.generated-by-smarthome-exchange create mode 100644 ts/integrations/opengarage/opengarage.classes.client.ts create mode 100644 ts/integrations/opengarage/opengarage.classes.configflow.ts create mode 100644 ts/integrations/opengarage/opengarage.discovery.ts create mode 100644 ts/integrations/opengarage/opengarage.mapper.ts delete mode 100644 ts/integrations/openhardwaremonitor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/openhardwaremonitor/openhardwaremonitor.classes.client.ts create mode 100644 ts/integrations/openhardwaremonitor/openhardwaremonitor.classes.configflow.ts create mode 100644 ts/integrations/openhardwaremonitor/openhardwaremonitor.discovery.ts create mode 100644 ts/integrations/openhardwaremonitor/openhardwaremonitor.mapper.ts delete mode 100644 ts/integrations/openhome/.generated-by-smarthome-exchange create mode 100644 ts/integrations/openhome/openhome.classes.client.ts create mode 100644 ts/integrations/openhome/openhome.classes.configflow.ts create mode 100644 ts/integrations/openhome/openhome.discovery.ts create mode 100644 ts/integrations/openhome/openhome.mapper.ts delete mode 100644 ts/integrations/opple/.generated-by-smarthome-exchange create mode 100644 ts/integrations/opple/opple.classes.client.ts create mode 100644 ts/integrations/opple/opple.classes.configflow.ts create mode 100644 ts/integrations/opple/opple.discovery.ts create mode 100644 ts/integrations/opple/opple.mapper.ts delete mode 100644 ts/integrations/oralb/.generated-by-smarthome-exchange create mode 100644 ts/integrations/oralb/oralb.classes.client.ts create mode 100644 ts/integrations/oralb/oralb.classes.configflow.ts create mode 100644 ts/integrations/oralb/oralb.discovery.ts create mode 100644 ts/integrations/oralb/oralb.mapper.ts delete mode 100644 ts/integrations/orvibo/.generated-by-smarthome-exchange create mode 100644 ts/integrations/orvibo/orvibo.classes.client.ts create mode 100644 ts/integrations/orvibo/orvibo.classes.configflow.ts create mode 100644 ts/integrations/orvibo/orvibo.discovery.ts create mode 100644 ts/integrations/orvibo/orvibo.mapper.ts delete mode 100644 ts/integrations/osramlightify/.generated-by-smarthome-exchange create mode 100644 ts/integrations/osramlightify/osramlightify.classes.client.ts create mode 100644 ts/integrations/osramlightify/osramlightify.classes.configflow.ts create mode 100644 ts/integrations/osramlightify/osramlightify.discovery.ts create mode 100644 ts/integrations/osramlightify/osramlightify.mapper.ts delete mode 100644 ts/integrations/otbr/.generated-by-smarthome-exchange create mode 100644 ts/integrations/otbr/otbr.classes.client.ts create mode 100644 ts/integrations/otbr/otbr.classes.configflow.ts create mode 100644 ts/integrations/otbr/otbr.discovery.ts create mode 100644 ts/integrations/otbr/otbr.mapper.ts delete mode 100644 ts/integrations/overkiz/.generated-by-smarthome-exchange create mode 100644 ts/integrations/overkiz/overkiz.classes.client.ts create mode 100644 ts/integrations/overkiz/overkiz.classes.configflow.ts create mode 100644 ts/integrations/overkiz/overkiz.discovery.ts create mode 100644 ts/integrations/overkiz/overkiz.mapper.ts delete mode 100644 ts/integrations/overseerr/.generated-by-smarthome-exchange create mode 100644 ts/integrations/overseerr/overseerr.classes.client.ts create mode 100644 ts/integrations/overseerr/overseerr.classes.configflow.ts create mode 100644 ts/integrations/overseerr/overseerr.discovery.ts create mode 100644 ts/integrations/overseerr/overseerr.mapper.ts delete mode 100644 ts/integrations/owntracks/.generated-by-smarthome-exchange create mode 100644 ts/integrations/owntracks/owntracks.classes.client.ts create mode 100644 ts/integrations/owntracks/owntracks.classes.configflow.ts create mode 100644 ts/integrations/owntracks/owntracks.discovery.ts create mode 100644 ts/integrations/owntracks/owntracks.mapper.ts delete mode 100644 ts/integrations/p1_monitor/.generated-by-smarthome-exchange create mode 100644 ts/integrations/p1_monitor/p1_monitor.classes.client.ts create mode 100644 ts/integrations/p1_monitor/p1_monitor.classes.configflow.ts create mode 100644 ts/integrations/p1_monitor/p1_monitor.discovery.ts create mode 100644 ts/integrations/p1_monitor/p1_monitor.mapper.ts delete mode 100644 ts/integrations/palazzetti/.generated-by-smarthome-exchange create mode 100644 ts/integrations/palazzetti/palazzetti.classes.client.ts create mode 100644 ts/integrations/palazzetti/palazzetti.classes.configflow.ts create mode 100644 ts/integrations/palazzetti/palazzetti.discovery.ts create mode 100644 ts/integrations/palazzetti/palazzetti.mapper.ts delete mode 100644 ts/integrations/panasonic_bluray/.generated-by-smarthome-exchange create mode 100644 ts/integrations/panasonic_bluray/panasonic_bluray.classes.client.ts create mode 100644 ts/integrations/panasonic_bluray/panasonic_bluray.classes.configflow.ts create mode 100644 ts/integrations/panasonic_bluray/panasonic_bluray.discovery.ts create mode 100644 ts/integrations/panasonic_bluray/panasonic_bluray.mapper.ts delete mode 100644 ts/integrations/panasonic_viera/.generated-by-smarthome-exchange create mode 100644 ts/integrations/panasonic_viera/panasonic_viera.classes.client.ts create mode 100644 ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.ts create mode 100644 ts/integrations/panasonic_viera/panasonic_viera.discovery.ts create mode 100644 ts/integrations/panasonic_viera/panasonic_viera.mapper.ts delete mode 100644 ts/integrations/paperless_ngx/.generated-by-smarthome-exchange create mode 100644 ts/integrations/paperless_ngx/paperless_ngx.classes.client.ts create mode 100644 ts/integrations/paperless_ngx/paperless_ngx.classes.configflow.ts create mode 100644 ts/integrations/paperless_ngx/paperless_ngx.discovery.ts create mode 100644 ts/integrations/paperless_ngx/paperless_ngx.mapper.ts delete mode 100644 ts/integrations/peblar/.generated-by-smarthome-exchange create mode 100644 ts/integrations/peblar/peblar.classes.client.ts create mode 100644 ts/integrations/peblar/peblar.classes.configflow.ts create mode 100644 ts/integrations/peblar/peblar.discovery.ts create mode 100644 ts/integrations/peblar/peblar.mapper.ts delete mode 100644 ts/integrations/pencom/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pencom/pencom.classes.client.ts create mode 100644 ts/integrations/pencom/pencom.classes.configflow.ts create mode 100644 ts/integrations/pencom/pencom.discovery.ts create mode 100644 ts/integrations/pencom/pencom.mapper.ts delete mode 100644 ts/integrations/pglab/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pglab/pglab.classes.client.ts create mode 100644 ts/integrations/pglab/pglab.classes.configflow.ts create mode 100644 ts/integrations/pglab/pglab.discovery.ts create mode 100644 ts/integrations/pglab/pglab.mapper.ts delete mode 100644 ts/integrations/philips_js/.generated-by-smarthome-exchange create mode 100644 ts/integrations/philips_js/philips_js.classes.client.ts create mode 100644 ts/integrations/philips_js/philips_js.classes.configflow.ts create mode 100644 ts/integrations/philips_js/philips_js.discovery.ts create mode 100644 ts/integrations/philips_js/philips_js.mapper.ts delete mode 100644 ts/integrations/picotts/.generated-by-smarthome-exchange create mode 100644 ts/integrations/picotts/picotts.classes.client.ts create mode 100644 ts/integrations/picotts/picotts.classes.configflow.ts create mode 100644 ts/integrations/picotts/picotts.discovery.ts create mode 100644 ts/integrations/picotts/picotts.mapper.ts delete mode 100644 ts/integrations/pilight/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pilight/pilight.classes.client.ts create mode 100644 ts/integrations/pilight/pilight.classes.configflow.ts create mode 100644 ts/integrations/pilight/pilight.discovery.ts create mode 100644 ts/integrations/pilight/pilight.mapper.ts delete mode 100644 ts/integrations/ping/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ping/ping.classes.client.ts create mode 100644 ts/integrations/ping/ping.classes.configflow.ts create mode 100644 ts/integrations/ping/ping.discovery.ts create mode 100644 ts/integrations/ping/ping.mapper.ts delete mode 100644 ts/integrations/pioneer/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pioneer/pioneer.classes.client.ts create mode 100644 ts/integrations/pioneer/pioneer.classes.configflow.ts create mode 100644 ts/integrations/pioneer/pioneer.discovery.ts create mode 100644 ts/integrations/pioneer/pioneer.mapper.ts delete mode 100644 ts/integrations/pjlink/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pjlink/pjlink.classes.client.ts create mode 100644 ts/integrations/pjlink/pjlink.classes.configflow.ts create mode 100644 ts/integrations/pjlink/pjlink.discovery.ts create mode 100644 ts/integrations/pjlink/pjlink.mapper.ts delete mode 100644 ts/integrations/plugwise/.generated-by-smarthome-exchange create mode 100644 ts/integrations/plugwise/plugwise.classes.client.ts create mode 100644 ts/integrations/plugwise/plugwise.classes.configflow.ts create mode 100644 ts/integrations/plugwise/plugwise.discovery.ts create mode 100644 ts/integrations/plugwise/plugwise.mapper.ts diff --git a/test/flexit_bacnet/test.flexit_bacnet.node.ts b/test/flexit_bacnet/test.flexit_bacnet.node.ts new file mode 100644 index 0000000..06f2390 --- /dev/null +++ b/test/flexit_bacnet/test.flexit_bacnet.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FlexitBacnetClient, FlexitBacnetConfigFlow, FlexitBacnetIntegration, FlexitBacnetMapper, HomeAssistantFlexitBacnetIntegration, createFlexitBacnetDiscoveryDescriptor, flexitBacnetProfile, type IFlexitBacnetSnapshot, type TFlexitBacnetRawData } from '../../ts/integrations/flexit_bacnet/index.js'; + +const rawData: TFlexitBacnetRawData = { + host: '192.0.2.10', + device_id: 2, + device_name: 'Flexit Nordic Test', + serial_number: 'FXBACNET123', + model: 'Nordic S4', + operation_mode: 'home', + ventilation_mode: 'home', + room_temperature: 21.4, + air_temp_setpoint_home: 20, + outside_air_temperature: 5.5, + supply_air_fan_rpm: 1180, + air_filter_polluted: false, + electric_heater: true, + fan_setpoint_supply_air_home: 55, +}; + +tap.test('matches manual Flexit BACnet candidates and creates config flow output', async () => { + const descriptor = createFlexitBacnetDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flexit_bacnet-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'FXBACNET123', host: '192.0.2.10', name: 'Flexit Nordic Test', metadata: { rawData, deviceId: 2 } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('flexit_bacnet'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FlexitBacnetConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.10'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Flexit BACnet raw snapshots to runtime devices and entities', async () => { + const client = new FlexitBacnetClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FlexitBacnetMapper.toSnapshotFromRaw({}, rawData); + const devices = FlexitBacnetMapper.toDevices(mappedSnapshot); + const entities = FlexitBacnetMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('flexit_bacnet'); + expect(devices[0].manufacturer).toEqual('Flexit'); + expect(entities.some((entityArg) => entityArg.id === 'climate.flexit_nordic_test_climate')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.flexit_nordic_test_air_filter_polluted')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'number.flexit_nordic_test_home_supply_fan_setpoint')).toBeTrue(); +}); + +tap.test('exposes Flexit BACnet read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FlexitBacnetIntegration(); + const alias = new HomeAssistantFlexitBacnetIntegration(); + expect(alias instanceof FlexitBacnetIntegration).toBeTrue(); + expect(alias.domain).toEqual('flexit_bacnet'); + expect(integration.status).toEqual('read-only-runtime'); + expect(flexitBacnetProfile.metadata.configFlow).toEqual(true); + expect(flexitBacnetProfile.metadata.qualityScale).toEqual('silver'); + expect(flexitBacnetProfile.metadata.requirements).toEqual(['flexit_bacnet==2.2.3']); + expect(flexitBacnetProfile.metadata.codeowners).toEqual(['@lellky', '@piotrbulinski']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'flexit_bacnet', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'flexit_bacnet', service: 'refresh', target: {} }); + const snapshot = status.data as IFlexitBacnetSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'climate')).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Flexit Nordic Test'); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.flexit_nordic_test_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/test/flic/test.flic.node.ts b/test/flic/test.flic.node.ts new file mode 100644 index 0000000..971ef10 --- /dev/null +++ b/test/flic/test.flic.node.ts @@ -0,0 +1,69 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FlicClient, FlicConfigFlow, FlicIntegration, FlicMapper, HomeAssistantFlicIntegration, createFlicDiscoveryDescriptor, flicProfile, type IFlicSnapshot, type TFlicRawData } from '../../ts/integrations/flic/index.js'; + +const rawData: TFlicRawData = { + host: 'localhost', + port: 5551, + buttons: [ + { address: '80:E4:DA:70:01:02', name: 'Kitchen Flic', is_on: true, click_type: 'single', queued_time: 0 }, + { address: '80:E4:DA:70:03:04', name: 'Hall Flic', is_on: false, click_type: 'hold', queued_time: 1 }, + ], +}; + +tap.test('matches manual Flic candidates and creates config flow output', async () => { + const descriptor = createFlicDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flic-manual-match'); + const result = await matcher!.matches({ source: 'manual', name: 'Flic buttons', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('flic'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FlicConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Flic raw snapshots to devices and entities', async () => { + const client = new FlicClient({ rawData, ignoredClickTypes: ['double'] }); + const snapshot = await client.getSnapshot(); + const devices = FlicMapper.toDevices(snapshot); + const entities = FlicMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('flic'); + expect(devices[0].model).toEqual('Flic Button'); + expect(entities.length).toEqual(2); + expect(entities[0].id).toEqual('binary_sensor.flic_button_80_e4_da_70_01_02'); + expect(entities[0].attributes?.eventName).toEqual('flic_click'); +}); + +tap.test('exposes Flic read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FlicIntegration(); + const alias = new HomeAssistantFlicIntegration(); + expect(alias instanceof FlicIntegration).toBeTrue(); + expect(alias.domain).toEqual('flic'); + expect(integration.status).toEqual('read-only-runtime'); + expect(flicProfile.metadata.configFlow).toEqual(false); + expect(flicProfile.metadata.qualityScale).toEqual('legacy'); + expect(flicProfile.metadata.requirements).toEqual(['pyflic==2.0.4']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'flic', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'flic', service: 'refresh', target: {} }); + const snapshot = status.data as IFlicSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'button_80_e4_da_70_01_02')).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Flic'); + + const command = await runtime.callService!({ domain: 'flic', 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/flux_led/test.flux_led.node.ts b/test/flux_led/test.flux_led.node.ts new file mode 100644 index 0000000..1b2bc06 --- /dev/null +++ b/test/flux_led/test.flux_led.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FluxLedClient, FluxLedConfigFlow, FluxLedIntegration, FluxLedMapper, HomeAssistantFluxLedIntegration, createFluxLedDiscoveryDescriptor, fluxLedProfile, type IFluxLedSnapshot, type TFluxLedRawData } from '../../ts/integrations/flux_led/index.js'; + +const rawData: TFluxLedRawData = { + ipaddr: '192.0.2.20', + id: 'accf23010203', + model: 'RGBW', + model_description: 'Magic Home RGBW', + model_num: 35, + version_num: 10, + is_on: true, + brightness: 128, + color_mode: 'rgbw', + rgb: [255, 32, 16], + effect: 'rainbow', + effect_list: ['rainbow', 'jump'], + speed: 47, + paired_remotes: 2, + operating_modes: ['rgb', 'rgbw'], + operating_mode: 'rgbw', + remote_access_host: 'remote.example', + remote_access_enabled: false, +}; + +tap.test('matches manual Magic Home candidates and creates config flow output', async () => { + const descriptor = createFluxLedDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flux_led-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.20', name: 'Magic Home RGBW 010203', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('flux_led'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FluxLedConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.20'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Magic Home raw snapshots to devices and entities', async () => { + const client = new FluxLedClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FluxLedMapper.toSnapshotFromRaw({}, rawData); + const devices = FluxLedMapper.toDevices(mappedSnapshot); + const entities = FluxLedMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('flux_led'); + expect(devices[0].manufacturer).toEqual('Zengge'); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === true)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'number.magic_home_rgbw_010203_effect_speed')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'button.magic_home_rgbw_010203_restart')).toBeTrue(); +}); + +tap.test('exposes Magic Home read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FluxLedIntegration(); + const alias = new HomeAssistantFluxLedIntegration(); + expect(alias instanceof FluxLedIntegration).toBeTrue(); + expect(alias.domain).toEqual('flux_led'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fluxLedProfile.metadata.configFlow).toEqual(true); + expect(Object.prototype.hasOwnProperty.call(fluxLedProfile.metadata, 'qualityScale')).toBeTrue(); + expect(fluxLedProfile.metadata.qualityScale).toBeUndefined(); + expect(fluxLedProfile.metadata.dependencies).toEqual(['network']); + expect(fluxLedProfile.metadata.requirements).toEqual(['flux-led==1.2.0']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'flux_led', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'flux_led', service: 'refresh', target: {} }); + const snapshot = status.data as IFluxLedSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'light')).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Magic Home RGBW 010203'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.magic_home_rgbw_010203_light' } }); + 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/folder/alpha.txt b/test/folder/alpha.txt new file mode 100644 index 0000000..d91b34d --- /dev/null +++ b/test/folder/alpha.txt @@ -0,0 +1 @@ +hello folder diff --git a/test/folder/test.folder.node.ts b/test/folder/test.folder.node.ts new file mode 100644 index 0000000..469c95e --- /dev/null +++ b/test/folder/test.folder.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FolderClient, FolderConfigFlow, FolderIntegration, FolderMapper, HomeAssistantFolderIntegration, createFolderDiscoveryDescriptor, folderProfile, type IFolderSnapshot, type TFolderRawData } from '../../ts/integrations/folder/index.js'; + +const fixtureFolder = 'test/folder'; +const rawData: TFolderRawData = { + folder: fixtureFolder, + filter: '*.txt', + number_of_files: 1, + bytes: 12, + file_list: [`${fixtureFolder}/alpha.txt`], +}; + +tap.test('matches manual Folder candidates and creates config flow output', async () => { + const descriptor = createFolderDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'folder-manual-match'); + const result = await matcher!.matches({ source: 'manual', name: 'Folder sensor', metadata: { rawData, folder: fixtureFolder, folderPath: fixtureFolder, filter: '*.txt' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('folder'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FolderConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); + expect(done.config?.metadata?.folderPath).toEqual(fixtureFolder); +}); + +tap.test('maps Folder raw snapshots to devices and entities', async () => { + const client = new FolderClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FolderMapper.toDevices(snapshot); + const entities = FolderMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('folder'); + expect(devices[0].model).toEqual('Local Folder Sensor'); + expect(entities.length).toEqual(1); + expect(entities[0].attributes?.bytes).toEqual(12); + expect(entities[0].attributes?.number_of_files).toEqual(1); +}); + +tap.test('reads local Folder snapshots and exposes read-only runtime with unsupported control', async () => { + const integration = new FolderIntegration(); + const alias = new HomeAssistantFolderIntegration(); + expect(alias instanceof FolderIntegration).toBeTrue(); + expect(alias.domain).toEqual('folder'); + expect(integration.status).toEqual('read-only-runtime'); + expect(folderProfile.metadata.configFlow).toEqual(false); + expect(folderProfile.metadata.qualityScale).toEqual('legacy'); + expect(folderProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ folder: fixtureFolder, filter: '*.txt' }, {}); + const status = await runtime.callService!({ domain: 'folder', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'folder', service: 'refresh', target: {} }); + const snapshot = status.data as IFolderSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.entities[0].attributes?.number_of_files).toEqual(1); + expect(Number(snapshot.entities[0].attributes?.bytes) > 0).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('folder'); + + const command = await runtime.callService!({ domain: 'folder', 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/folder_watcher/event.yaml b/test/folder_watcher/event.yaml new file mode 100644 index 0000000..9658b28 --- /dev/null +++ b/test/folder_watcher/event.yaml @@ -0,0 +1 @@ +event: created diff --git a/test/folder_watcher/ignore.txt b/test/folder_watcher/ignore.txt new file mode 100644 index 0000000..592fd25 --- /dev/null +++ b/test/folder_watcher/ignore.txt @@ -0,0 +1 @@ +ignore me diff --git a/test/folder_watcher/test.folder_watcher.node.ts b/test/folder_watcher/test.folder_watcher.node.ts new file mode 100644 index 0000000..5505175 --- /dev/null +++ b/test/folder_watcher/test.folder_watcher.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FolderWatcherClient, FolderWatcherConfigFlow, FolderWatcherIntegration, FolderWatcherMapper, HomeAssistantFolderWatcherIntegration, createFolderWatcherDiscoveryDescriptor, folderWatcherProfile, type IFolderWatcherSnapshot, type TFolderWatcherRawData } from '../../ts/integrations/folder_watcher/index.js'; + +const fixtureFolder = 'test/folder_watcher'; +const rawData: TFolderWatcherRawData = { + folder: fixtureFolder, + patterns: ['*.yaml'], + watched_files: 1, + last_event: { + event_type: 'created', + path: `${fixtureFolder}/event.yaml`, + file: 'event.yaml', + folder: fixtureFolder, + }, +}; + +tap.test('matches manual Folder Watcher candidates and creates config flow output', async () => { + const descriptor = createFolderWatcherDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'folder_watcher-manual-match'); + const result = await matcher!.matches({ source: 'manual', name: 'Folder watcher', metadata: { rawData, folder: fixtureFolder, patterns: ['*.yaml'] } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('folder_watcher'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FolderWatcherConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.rawData).toEqual(rawData); + expect(done.config?.metadata?.folder).toEqual(fixtureFolder); +}); + +tap.test('maps Folder Watcher raw snapshots to devices and entities', async () => { + const client = new FolderWatcherClient({ rawData }); + const snapshot = await client.getSnapshot(); + const devices = FolderWatcherMapper.toDevices(snapshot); + const entities = FolderWatcherMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('folder_watcher'); + expect(devices[0].model).toEqual('Watchdog folder observer'); + expect(entities.length).toEqual(1); + expect(entities[0].state).toEqual('created'); + expect(entities[0].attributes?.haPlatform).toEqual('event'); +}); + +tap.test('reads local Folder Watcher snapshots and exposes read-only runtime with unsupported control', async () => { + const integration = new FolderWatcherIntegration(); + const alias = new HomeAssistantFolderWatcherIntegration(); + expect(alias instanceof FolderWatcherIntegration).toBeTrue(); + expect(alias.domain).toEqual('folder_watcher'); + expect(integration.status).toEqual('read-only-runtime'); + expect(folderWatcherProfile.metadata.configFlow).toEqual(true); + expect(folderWatcherProfile.metadata.qualityScale).toEqual('internal'); + expect(folderWatcherProfile.metadata.requirements).toEqual(['watchdog==6.0.0']); + + const runtime = await integration.setup({ folder: fixtureFolder, patterns: ['*.yaml'] }, {}); + const status = await runtime.callService!({ domain: 'folder_watcher', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'folder_watcher', service: 'refresh', target: {} }); + const snapshot = status.data as IFolderWatcherSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.entities[0].state).toEqual('idle'); + expect(snapshot.entities[0].attributes?.watched_files).toEqual(1); + expect((await runtime.devices())[0].name).toEqual(`Folder Watcher ${fixtureFolder}`); + + const command = await runtime.callService!({ domain: 'folder_watcher', 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/fortios/test.fortios.node.ts b/test/fortios/test.fortios.node.ts new file mode 100644 index 0000000..fc0c8f9 --- /dev/null +++ b/test/fortios/test.fortios.node.ts @@ -0,0 +1,90 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FortiosClient, FortiosConfigFlow, FortiosIntegration, FortiosMapper, HomeAssistantFortiosIntegration, createFortiosDiscoveryDescriptor, fortiosProfile, type IFortiosSnapshot, type TFortiosRawData } from '../../ts/integrations/fortios/index.js'; + +const rawData: TFortiosRawData = { + device: { + id: 'fortigate-60f', + name: 'FortiGate 60F', + manufacturer: 'Fortinet', + model: 'FortiGate 60F', + serialNumber: 'FG60FTK000000001', + host: '192.0.2.10', + }, + entities: [ + { + id: 'aa_bb_cc_dd_ee_ff', + name: 'Laptop', + platform: 'binary_sensor', + state: true, + deviceClass: 'presence', + attributes: { + macAddress: 'AA:BB:CC:DD:EE:FF', + hostname: 'laptop', + }, + }, + { id: 'firmware_version', name: 'Firmware Version', platform: 'sensor', state: '7.2.8' }, + ], + clients: [ + { master_mac: 'AA:BB:CC:DD:EE:FF', hostname: 'laptop', is_online: true }, + ], +}; + +tap.test('matches manual FortiOS candidates and creates config flow output', async () => { + const descriptor = createFortiosDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fortios-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'fortigate-60f', name: 'FortiGate 60F', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fortios'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FortiosConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('FortiGate 60F'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps FortiOS raw snapshots to runtime devices and entities', async () => { + const client = new FortiosClient({ name: 'FortiGate 60F', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FortiosMapper.toSnapshotFromRaw({ name: 'FortiGate 60F' }, rawData); + const devices = FortiosMapper.toDevices(mappedSnapshot); + const entities = FortiosMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fortios'); + expect(devices[0].manufacturer).toEqual('Fortinet'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.fortigate_60f_aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.fortigate_60f_firmware_version')).toBeTrue(); +}); + +tap.test('exposes FortiOS read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FortiosIntegration(); + const alias = new HomeAssistantFortiosIntegration(); + expect(alias instanceof FortiosIntegration).toBeTrue(); + expect(alias.domain).toEqual('fortios'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fortiosProfile.metadata.configFlow).toEqual(false); + expect(fortiosProfile.metadata.qualityScale).toEqual('legacy'); + expect(fortiosProfile.metadata.requirements).toEqual(['fortiosapi==1.0.5']); + + const runtime = await integration.setup({ name: 'FortiGate 60F', rawData }, {}); + const status = await runtime.callService!({ domain: 'fortios', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'fortios', service: 'refresh', target: {} }); + const snapshot = status.data as IFortiosSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('FortiGate 60F'); + + const command = await runtime.callService!({ domain: 'fortios', service: 'turn_on', target: { entityId: 'binary_sensor.fortigate_60f_aa_bb_cc_dd_ee_ff' } }); + 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/fritzbox/test.fritzbox.node.ts b/test/fritzbox/test.fritzbox.node.ts new file mode 100644 index 0000000..9694e8c --- /dev/null +++ b/test/fritzbox/test.fritzbox.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FritzboxClient, FritzboxConfigFlow, FritzboxIntegration, FritzboxMapper, HomeAssistantFritzboxIntegration, createFritzboxDiscoveryDescriptor, fritzboxProfile, type IFritzboxSnapshot, type TFritzboxRawData } from '../../ts/integrations/fritzbox/index.js'; + +const rawData: TFritzboxRawData = { + device: { + id: 'fritz-ain-1', + name: 'FRITZ SmartHome Hub', + manufacturer: 'AVM', + model: 'FRITZ!Box 7590', + host: '192.0.2.20', + }, + entities: [ + { id: 'outlet', name: 'Outlet', platform: 'switch', state: true, writable: true, attributes: { ain: '08761 0000001' } }, + { id: 'temperature', name: 'Temperature', platform: 'sensor', state: 21.5, unit: 'C', deviceClass: 'temperature' }, + { id: 'window_open', name: 'Window Open', platform: 'binary_sensor', state: false, deviceClass: 'window' }, + { id: 'bulb', name: 'Bulb', platform: 'light', state: true, writable: true, attributes: { brightness: 180, colorMode: 'brightness' } }, + { id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'heat', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 22, hvacMode: 'heat', presetMode: 'comfort' } }, + { id: 'blind', name: 'Blind', platform: 'cover', state: 'open', writable: true, attributes: { position: 75 } }, + { id: 'template', name: 'Template', platform: 'button', state: 'ready', writable: true }, + ], +}; + +tap.test('matches manual FRITZ!SmartHome candidates and creates config flow output', async () => { + const descriptor = createFritzboxDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fritzbox-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'fritz-ain-1', name: 'FRITZ SmartHome Hub', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fritzbox'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FritzboxConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('FRITZ SmartHome Hub'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps FRITZ!SmartHome raw snapshots to runtime devices and entities', async () => { + const client = new FritzboxClient({ name: 'FRITZ SmartHome Hub', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FritzboxMapper.toSnapshotFromRaw({ name: 'FRITZ SmartHome Hub' }, rawData); + const devices = FritzboxMapper.toDevices(mappedSnapshot); + const entities = FritzboxMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('fritzbox'); + expect(devices[0].manufacturer).toEqual('AVM'); + expect(entities.some((entityArg) => entityArg.id === 'switch.fritz_smarthome_hub_outlet')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'climate.fritz_smarthome_hub_thermostat')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'button.fritz_smarthome_hub_template')).toBeTrue(); +}); + +tap.test('exposes FRITZ!SmartHome read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FritzboxIntegration(); + const alias = new HomeAssistantFritzboxIntegration(); + expect(alias instanceof FritzboxIntegration).toBeTrue(); + expect(alias.domain).toEqual('fritzbox'); + expect(integration.status).toEqual('read-only-runtime'); + expect(fritzboxProfile.metadata.configFlow).toEqual(true); + expect(fritzboxProfile.metadata.integrationType).toEqual('hub'); + expect(fritzboxProfile.metadata.requirements).toEqual(['pyfritzhome==0.6.20']); + + const runtime = await integration.setup({ name: 'FRITZ SmartHome Hub', rawData }, {}); + const status = await runtime.callService!({ domain: 'fritzbox', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'fritzbox', service: 'refresh', target: {} }); + const snapshot = status.data as IFritzboxSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('FRITZ SmartHome Hub'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.fritz_smarthome_hub_outlet' } }); + 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/futurenow/test.futurenow.node.ts b/test/futurenow/test.futurenow.node.ts new file mode 100644 index 0000000..6178e04 --- /dev/null +++ b/test/futurenow/test.futurenow.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FuturenowClient, FuturenowConfigFlow, FuturenowIntegration, FuturenowMapper, HomeAssistantFuturenowIntegration, createFuturenowDiscoveryDescriptor, futurenowProfile, type IFuturenowSnapshot, type TFuturenowRawData } from '../../ts/integrations/futurenow/index.js'; + +const rawData: TFuturenowRawData = { + device: { + id: 'fnip-controller', + name: 'FutureNow Controller', + manufacturer: 'FutureNow', + model: 'FNIP8x10a', + host: '192.0.2.30', + port: 1024, + }, + entities: [ + { id: 'kitchen', name: 'Kitchen', platform: 'light', state: true, writable: true, attributes: { channel: '1', brightness: 204, dimmable: true, driver: 'FNIP8x10a' } }, + { id: 'hallway', name: 'Hallway', platform: 'light', state: false, writable: true, attributes: { channel: '2', brightness: 0, dimmable: false, driver: 'FNIP8x10a' } }, + ], +}; + +tap.test('matches manual FutureNow candidates and creates config flow output', async () => { + const descriptor = createFuturenowDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'futurenow-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'fnip-controller', name: 'FutureNow Controller', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('futurenow'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new FuturenowConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('FutureNow Controller'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps FutureNow raw snapshots to runtime devices and entities', async () => { + const client = new FuturenowClient({ name: 'FutureNow Controller', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = FuturenowMapper.toSnapshotFromRaw({ name: 'FutureNow Controller' }, rawData); + const devices = FuturenowMapper.toDevices(mappedSnapshot); + const entities = FuturenowMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('futurenow'); + expect(devices[0].manufacturer).toEqual('FutureNow'); + expect(entities.some((entityArg) => entityArg.id === 'light.futurenow_controller_kitchen')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.futurenow_controller_hallway')).toBeTrue(); +}); + +tap.test('exposes FutureNow read-only runtime, HA alias, and unsupported control', async () => { + const integration = new FuturenowIntegration(); + const alias = new HomeAssistantFuturenowIntegration(); + expect(alias instanceof FuturenowIntegration).toBeTrue(); + expect(alias.domain).toEqual('futurenow'); + expect(integration.status).toEqual('read-only-runtime'); + expect(futurenowProfile.metadata.configFlow).toEqual(false); + expect(futurenowProfile.metadata.qualityScale).toEqual('legacy'); + expect(futurenowProfile.metadata.requirements).toEqual(['pyfnip==0.2']); + + const runtime = await integration.setup({ name: 'FutureNow Controller', rawData }, {}); + const status = await runtime.callService!({ domain: 'futurenow', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'futurenow', service: 'refresh', target: {} }); + const snapshot = status.data as IFuturenowSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('FutureNow Controller'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.futurenow_controller_kitchen' }, data: { brightness: 255 } }); + 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/gardena_bluetooth/test.gardena_bluetooth.node.ts b/test/gardena_bluetooth/test.gardena_bluetooth.node.ts new file mode 100644 index 0000000..b429ddf --- /dev/null +++ b/test/gardena_bluetooth/test.gardena_bluetooth.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GardenaBluetoothClient, GardenaBluetoothConfigFlow, GardenaBluetoothIntegration, GardenaBluetoothMapper, HomeAssistantGardenaBluetoothIntegration, createGardenaBluetoothDiscoveryDescriptor, gardenaBluetoothProfile, gardenaBluetoothServiceUuid, type IGardenaBluetoothSnapshot, type TGardenaBluetoothRawData } from '../../ts/integrations/gardena_bluetooth/index.js'; + +const rawData: TGardenaBluetoothRawData = { + device: { + id: 'gardena-aa-bb-cc-dd-ee-ff', + name: 'Gardena Valve', + manufacturer: 'Gardena', + model: 'Water Computer Bluetooth', + serialNumber: 'AA:BB:CC:DD:EE:FF', + }, + entities: [ + { id: 'valve_connected_state', name: 'Valve Connection', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' }, + { id: 'battery_level', name: 'Battery Level', platform: 'sensor', state: 87, unit: '%', deviceClass: 'battery' }, + { id: 'manual_watering_time', name: 'Manual Watering Time', platform: 'number', state: 900, writable: true, unit: 's', deviceClass: 'duration' }, + { id: 'valve', name: 'Valve', platform: 'switch', state: false, writable: true, deviceClass: 'water', attributes: { remainingOpenTime: 0 } }, + { id: 'operation_mode', name: 'Operation Mode', platform: 'select', state: 'active', writable: true, attributes: { options: ['active', 'manual_mode', 'deep_sleep'] } }, + { id: 'custom_device_name', name: 'Custom Device Name', platform: 'text', state: 'Front Garden', writable: true }, + { id: 'factory_reset', name: 'Factory Reset', platform: 'button', state: 'ready', writable: true }, + ], +}; + +tap.test('matches manual Gardena Bluetooth candidates and creates config flow output', async () => { + const descriptor = createGardenaBluetoothDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gardena_bluetooth-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Gardena Valve', metadata: { rawData, serviceUuid: gardenaBluetoothServiceUuid } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gardena_bluetooth'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GardenaBluetoothConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Gardena Valve'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Gardena Bluetooth raw snapshots to runtime devices and entities', async () => { + const client = new GardenaBluetoothClient({ name: 'Gardena Valve', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GardenaBluetoothMapper.toSnapshotFromRaw({ name: 'Gardena Valve' }, rawData); + const devices = GardenaBluetoothMapper.toDevices(mappedSnapshot); + const entities = GardenaBluetoothMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('gardena_bluetooth'); + expect(devices[0].manufacturer).toEqual('Gardena'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.gardena_valve_valve_connected_state')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.gardena_valve_valve')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'number.gardena_valve_manual_watering_time')).toBeTrue(); +}); + +tap.test('exposes Gardena Bluetooth read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GardenaBluetoothIntegration(); + const alias = new HomeAssistantGardenaBluetoothIntegration(); + expect(alias instanceof GardenaBluetoothIntegration).toBeTrue(); + expect(alias.domain).toEqual('gardena_bluetooth'); + expect(integration.status).toEqual('read-only-runtime'); + expect(gardenaBluetoothProfile.metadata.configFlow).toEqual(true); + expect(gardenaBluetoothProfile.metadata.dependencies).toEqual(['bluetooth_adapters']); + expect(gardenaBluetoothProfile.metadata.requirements).toEqual(['gardena-bluetooth==2.4.0']); + + const runtime = await integration.setup({ name: 'Gardena Valve', rawData }, {}); + const status = await runtime.callService!({ domain: 'gardena_bluetooth', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gardena_bluetooth', service: 'refresh', target: {} }); + const snapshot = status.data as IGardenaBluetoothSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Gardena Valve'); + + const command = await runtime.callService!({ domain: 'valve', service: 'open_valve', target: { entityId: 'switch.gardena_valve_valve' } }); + 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/gc100/test.gc100.node.ts b/test/gc100/test.gc100.node.ts new file mode 100644 index 0000000..a44d4ed --- /dev/null +++ b/test/gc100/test.gc100.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { Gc100Client, Gc100ConfigFlow, Gc100Integration, Gc100Mapper, HomeAssistantGc100Integration, createGc100DiscoveryDescriptor, gc100Profile, type IGc100Snapshot, type TGc100RawData } from '../../ts/integrations/gc100/index.js'; + +const rawData: TGc100RawData = { + device: { + id: 'gc100-1', + name: 'Global Cache GC-100', + manufacturer: 'Global Cache', + model: 'GC-100', + host: '192.0.2.40', + port: 4998, + }, + entities: [ + { id: 'input_1_1', name: 'Input 1:1', platform: 'binary_sensor', state: true, deviceClass: 'opening', attributes: { portAddress: '1:1' } }, + { id: 'relay_1_2', name: 'Relay 1:2', platform: 'switch', state: false, writable: true, attributes: { portAddress: '1:2' } }, + ], +}; + +tap.test('matches manual GC-100 candidates and creates config flow output', async () => { + const descriptor = createGc100DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gc100-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'gc100-1', name: 'Global Cache GC-100', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gc100'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Gc100ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Global Cache GC-100'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps GC-100 raw snapshots to runtime devices and entities', async () => { + const client = new Gc100Client({ name: 'Global Cache GC-100', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = Gc100Mapper.toSnapshotFromRaw({ name: 'Global Cache GC-100' }, rawData); + const devices = Gc100Mapper.toDevices(mappedSnapshot); + const entities = Gc100Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('gc100'); + expect(devices[0].manufacturer).toEqual('Global Cache'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.global_cache_gc_100_input_1_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.global_cache_gc_100_relay_1_2')).toBeTrue(); +}); + +tap.test('exposes GC-100 read-only runtime, HA alias, and unsupported control', async () => { + const integration = new Gc100Integration(); + const alias = new HomeAssistantGc100Integration(); + expect(alias instanceof Gc100Integration).toBeTrue(); + expect(alias.domain).toEqual('gc100'); + expect(integration.status).toEqual('read-only-runtime'); + expect(gc100Profile.metadata.configFlow).toEqual(false); + expect(gc100Profile.metadata.qualityScale).toEqual('legacy'); + expect(gc100Profile.metadata.requirements).toEqual(['python-gc100==1.0.3a0']); + + const runtime = await integration.setup({ name: 'Global Cache GC-100', rawData }, {}); + const status = await runtime.callService!({ domain: 'gc100', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gc100', service: 'refresh', target: {} }); + const snapshot = status.data as IGc100Snapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Global Cache GC-100'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.global_cache_gc_100_relay_1_2' } }); + 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/generic/test.generic.node.ts b/test/generic/test.generic.node.ts new file mode 100644 index 0000000..fe0f977 --- /dev/null +++ b/test/generic/test.generic.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GenericClient, GenericConfigFlow, GenericIntegration, GenericMapper, HomeAssistantGenericIntegration, createGenericDiscoveryDescriptor, genericProfile, type IGenericSnapshot, type TGenericRawData } from '../../ts/integrations/generic/index.js'; + +const rawData: TGenericRawData = { + name: 'Front Door Camera', + still_image_url: 'http://camera.local/still.jpg', + stream_source: 'rtsp://camera.local/live', + content_type: 'image/jpeg', + advanced: { + framerate: 2, + verify_ssl: true, + rtsp_transport: 'tcp', + authentication: 'basic', + }, +}; + +tap.test('matches manual Generic Camera candidates and creates config flow output', async () => { + const descriptor = createGenericDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'generic-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'front-camera', name: 'Front Door Camera', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('generic'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GenericConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Front Door Camera'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Generic Camera raw snapshots to runtime devices and entities', async () => { + const client = new GenericClient({ name: 'Front Door Camera', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GenericMapper.toSnapshotFromRaw({ name: 'Front Door Camera' }, rawData); + const devices = GenericMapper.toDevices(mappedSnapshot); + const entities = GenericMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.manufacturer).toEqual('Generic'); + expect(devices[0].integrationDomain).toEqual('generic'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.front_door_camera_camera')).toBeTrue(); + expect(entities[0].attributes?.streamSource).toEqual('rtsp://camera.local/live'); +}); + +tap.test('exposes Generic Camera read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GenericIntegration(); + const alias = new HomeAssistantGenericIntegration(); + expect(alias instanceof GenericIntegration).toBeTrue(); + expect(alias.domain).toEqual('generic'); + expect(integration.status).toEqual('read-only-runtime'); + expect(genericProfile.metadata.configFlow).toEqual(true); + expect(genericProfile.metadata.iotClass).toEqual('local_push'); + expect(genericProfile.metadata.dependencies).toEqual(['http', 'stream']); + + const runtime = await integration.setup({ name: 'Front Door Camera', rawData }, {}); + const status = await runtime.callService!({ domain: 'generic', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'generic', service: 'refresh', target: {} }); + const snapshot = status.data as IGenericSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Front Door Camera'); + + const command = await runtime.callService!({ domain: 'camera', service: 'turn_on', target: { entityId: 'camera.front_door_camera' } }); + 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/geniushub/test.geniushub.node.ts b/test/geniushub/test.geniushub.node.ts new file mode 100644 index 0000000..b123782 --- /dev/null +++ b/test/geniushub/test.geniushub.node.ts @@ -0,0 +1,86 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GeniushubClient, GeniushubConfigFlow, GeniushubIntegration, GeniushubMapper, HomeAssistantGeniushubIntegration, createGeniushubDiscoveryDescriptor, geniushubProfile, type IGeniushubSnapshot, type TGeniushubRawData } from '../../ts/integrations/geniushub/index.js'; + +const rawData: TGeniushubRawData = { + hubUid: 'hub-001', + zones: [ + { id: 1, name: 'Kitchen', type: 'radiator', mode: 'timer', temperature: 21.2, setpoint: 20.5, occupied: true, output: 1 }, + { id: 2, name: 'Hot Water Pump', type: 'on / off', mode: 'override', setpoint: 1 }, + ], + devices: [ + { + id: 11, + type: 'Receiver', + data: { + assignedZones: [{ name: 'Kitchen' }], + state: { + outputOnOff: true, + batteryLevel: 88, + }, + }, + }, + ], + issues: [ + { level: 'warning', description: 'Receiver battery low' }, + ], +}; + +tap.test('matches manual Genius Hub candidates and creates config flow output', async () => { + const descriptor = createGeniushubDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'geniushub-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'hub-001', name: 'Genius Hub', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('geniushub'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GeniushubConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Genius Hub'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Genius Hub raw snapshots to runtime devices and entities', async () => { + const client = new GeniushubClient({ name: 'Genius Hub', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GeniushubMapper.toSnapshotFromRaw({ name: 'Genius Hub' }, rawData); + const devices = GeniushubMapper.toDevices(mappedSnapshot); + const entities = GeniushubMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('geniushub'); + expect(entities.some((entityArg) => entityArg.id === 'climate.genius_hub_zone_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.genius_hub_zone_2')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.genius_hub_device_11_battery')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.genius_hub_device_11_output')).toBeTrue(); +}); + +tap.test('exposes Genius Hub read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GeniushubIntegration(); + const alias = new HomeAssistantGeniushubIntegration(); + expect(alias instanceof GeniushubIntegration).toBeTrue(); + expect(alias.domain).toEqual('geniushub'); + expect(integration.status).toEqual('read-only-runtime'); + expect(geniushubProfile.metadata.configFlow).toEqual(true); + expect(geniushubProfile.metadata.requirements).toEqual(['geniushub-client==0.7.1']); + expect(geniushubProfile.metadata.codeowners).toEqual(['@manzanotti']); + + const runtime = await integration.setup({ name: 'Genius Hub', rawData }, {}); + const status = await runtime.callService!({ domain: 'geniushub', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'geniushub', service: 'refresh', target: {} }); + const snapshot = status.data as IGeniushubSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Genius Hub'); + + const command = await runtime.callService!({ domain: 'geniushub', service: 'set_zone_mode', target: { entityId: 'climate.genius_hub_zone_1' }, data: { mode: 'off' } }); + 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/goalzero/test.goalzero.node.ts b/test/goalzero/test.goalzero.node.ts new file mode 100644 index 0000000..ef12ca8 --- /dev/null +++ b/test/goalzero/test.goalzero.node.ts @@ -0,0 +1,89 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GoalzeroClient, GoalzeroConfigFlow, GoalzeroIntegration, GoalzeroMapper, HomeAssistantGoalzeroIntegration, createGoalzeroDiscoveryDescriptor, goalzeroProfile, type IGoalzeroSnapshot, type TGoalzeroRawData } from '../../ts/integrations/goalzero/index.js'; + +const rawData: TGoalzeroRawData = { + sysdata: { + macAddress: 'AA:BB:CC:DD:EE:FF', + model: 'Yeti 1500X', + }, + data: { + firmwareVersion: '1.2.3', + wattsIn: 120, + wattsOut: 40, + whStored: 900, + socPercent: 75, + timeToEmptyFull: 360, + temperature: 31, + wifiStrength: -50, + ssid: 'camp', + ipAddr: '192.168.1.42', + timestamp: 1234, + app_online: 1, + backlight: 0, + isCharging: 1, + inputDetected: 1, + v12PortStatus: 1, + usbPortStatus: 0, + acPortStatus: 1, + }, +}; + +tap.test('matches manual Goal Zero candidates and creates config flow output', async () => { + const descriptor = createGoalzeroDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'goalzero-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Yeti', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('goalzero'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GoalzeroConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Yeti'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Goal Zero raw snapshots to runtime devices and entities', async () => { + const client = new GoalzeroClient({ name: 'Goal Zero Yeti', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GoalzeroMapper.toSnapshotFromRaw({ name: 'Goal Zero Yeti' }, rawData); + const devices = GoalzeroMapper.toDevices(mappedSnapshot); + const entities = GoalzeroMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.model).toEqual('Yeti 1500X'); + expect(devices[0].manufacturer).toEqual('Goal Zero'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.goal_zero_yeti_wattsin')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.goal_zero_yeti_ischarging')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.goal_zero_yeti_acportstatus')).toBeTrue(); +}); + +tap.test('exposes Goal Zero read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GoalzeroIntegration(); + const alias = new HomeAssistantGoalzeroIntegration(); + expect(alias instanceof GoalzeroIntegration).toBeTrue(); + expect(alias.domain).toEqual('goalzero'); + expect(integration.status).toEqual('read-only-runtime'); + expect(goalzeroProfile.metadata.configFlow).toEqual(true); + expect(goalzeroProfile.metadata.requirements).toEqual(['goalzero==0.2.2']); + expect(goalzeroProfile.metadata.codeowners).toEqual(['@tkdrob']); + + const runtime = await integration.setup({ name: 'Goal Zero Yeti', rawData }, {}); + const status = await runtime.callService!({ domain: 'goalzero', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'goalzero', service: 'refresh', target: {} }); + const snapshot = status.data as IGoalzeroSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Goal Zero Yeti'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.goal_zero_yeti_acportstatus' } }); + 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/gogogate2/test.gogogate2.node.ts b/test/gogogate2/test.gogogate2.node.ts new file mode 100644 index 0000000..3b34b05 --- /dev/null +++ b/test/gogogate2/test.gogogate2.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { Gogogate2Client, Gogogate2ConfigFlow, Gogogate2Integration, Gogogate2Mapper, HomeAssistantGogogate2Integration, createGogogate2DiscoveryDescriptor, gogogate2Profile, type IGogogate2Snapshot, type TGogogate2RawData } from '../../ts/integrations/gogogate2/index.js'; + +const rawData: TGogogate2RawData = { + model: 'iSmartGate', + firmwareversion: '2.1.0', + remoteaccessenabled: false, + remoteaccess: 'garage.example.com', + doors: [ + { door_id: 1, name: 'Garage Door', gate: false, status: 'closed', sensorid: 'ABC123', voltage: 92, temperature: 18.5 }, + ], +}; + +tap.test('matches manual Gogogate2 candidates and creates config flow output', async () => { + const descriptor = createGogogate2DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gogogate2-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'garage-hub', name: 'Garage Controller', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gogogate2'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Gogogate2ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Garage Controller'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Gogogate2 raw snapshots to runtime devices and entities', async () => { + const client = new Gogogate2Client({ name: 'Garage Controller', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = Gogogate2Mapper.toSnapshotFromRaw({ name: 'Garage Controller' }, rawData); + const devices = Gogogate2Mapper.toDevices(mappedSnapshot); + const entities = Gogogate2Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.model).toEqual('iSmartGate'); + expect(devices[0].manufacturer).toEqual('Remsol'); + expect(entities.some((entityArg) => entityArg.id === 'cover.garage_controller_door_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.garage_controller_door_1_battery')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.garage_controller_door_1_temperature')).toBeTrue(); +}); + +tap.test('exposes Gogogate2 read-only runtime, HA alias, and unsupported control', async () => { + const integration = new Gogogate2Integration(); + const alias = new HomeAssistantGogogate2Integration(); + expect(alias instanceof Gogogate2Integration).toBeTrue(); + expect(alias.domain).toEqual('gogogate2'); + expect(integration.status).toEqual('read-only-runtime'); + expect(gogogate2Profile.metadata.configFlow).toEqual(true); + expect(gogogate2Profile.metadata.requirements).toEqual(['ismartgate==5.0.2']); + expect(gogogate2Profile.metadata.codeowners).toEqual(['@vangorra']); + + const runtime = await integration.setup({ name: 'Garage Controller', rawData }, {}); + const status = await runtime.callService!({ domain: 'gogogate2', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gogogate2', service: 'refresh', target: {} }); + const snapshot = status.data as IGogogate2Snapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Garage Controller'); + + const command = await runtime.callService!({ domain: 'cover', service: 'open_cover', target: { entityId: 'cover.garage_controller_door_1' } }); + 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/google_wifi/test.google_wifi.node.ts b/test/google_wifi/test.google_wifi.node.ts new file mode 100644 index 0000000..1ddd230 --- /dev/null +++ b/test/google_wifi/test.google_wifi.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GoogleWifiClient, GoogleWifiConfigFlow, GoogleWifiIntegration, GoogleWifiMapper, HomeAssistantGoogleWifiIntegration, createGoogleWifiDiscoveryDescriptor, googleWifiProfile, type IGoogleWifiSnapshot, type TGoogleWifiRawData } from '../../ts/integrations/google_wifi/index.js'; + +const rawData: TGoogleWifiRawData = { + software: { + softwareVersion: '14150.43.80', + updateNewVersion: '0.0.0.0', + }, + system: { + uptime: 86400, + }, + wan: { + localIpAddress: '100.64.1.2', + online: true, + }, +}; + +tap.test('matches manual Google Wifi candidates and creates config flow output', async () => { + const descriptor = createGoogleWifiDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'google_wifi-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'google-wifi', name: 'Google Wifi', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('google_wifi'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GoogleWifiConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Google Wifi'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Google Wifi raw snapshots to runtime devices and entities', async () => { + const client = new GoogleWifiClient({ name: 'Google Wifi', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GoogleWifiMapper.toSnapshotFromRaw({ name: 'Google Wifi' }, rawData); + const devices = GoogleWifiMapper.toDevices(mappedSnapshot); + const entities = GoogleWifiMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('google_wifi'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_current_version')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_new_version' && entityArg.state === 'Latest')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_uptime' && entityArg.state === 1)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_status' && entityArg.state === 'Online')).toBeTrue(); +}); + +tap.test('exposes Google Wifi read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GoogleWifiIntegration(); + const alias = new HomeAssistantGoogleWifiIntegration(); + expect(alias instanceof GoogleWifiIntegration).toBeTrue(); + expect(alias.domain).toEqual('google_wifi'); + expect(integration.status).toEqual('read-only-runtime'); + expect(googleWifiProfile.metadata.configFlow).toEqual(false); + expect(googleWifiProfile.metadata.qualityScale).toEqual('legacy'); + expect(googleWifiProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: 'Google Wifi', rawData }, {}); + const status = await runtime.callService!({ domain: 'google_wifi', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'google_wifi', service: 'refresh', target: {} }); + const snapshot = status.data as IGoogleWifiSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Google Wifi'); + + const command = await runtime.callService!({ domain: 'google_wifi', service: 'turn_on', target: { entityId: 'sensor.google_wifi_status' } }); + 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/govee_ble/test.govee_ble.node.ts b/test/govee_ble/test.govee_ble.node.ts new file mode 100644 index 0000000..d3016f9 --- /dev/null +++ b/test/govee_ble/test.govee_ble.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GoveeBleClient, GoveeBleConfigFlow, GoveeBleIntegration, GoveeBleMapper, HomeAssistantGoveeBleIntegration, createGoveeBleDiscoveryDescriptor, goveeBleProfile, type IGoveeBleSnapshot, type TGoveeBleRawData } from '../../ts/integrations/govee_ble/index.js'; + +const rawData: TGoveeBleRawData = { + address: 'AA:BB:CC:DD:EE:FF', + deviceType: 'H5075', + name: 'Govee H5075', + sensors: { + temperature: 21.6, + humidity: 48, + battery: 91, + rssi: -63, + }, + binarySensors: { + motion: false, + }, +}; + +tap.test('matches manual Govee BLE candidates and creates config flow output', async () => { + const descriptor = createGoveeBleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'govee_ble-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Govee H5075', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('govee_ble'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GoveeBleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Govee H5075'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Govee BLE raw snapshots to runtime devices and entities', async () => { + const client = new GoveeBleClient({ name: 'Govee H5075', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GoveeBleMapper.toSnapshotFromRaw({ name: 'Govee H5075' }, rawData); + const devices = GoveeBleMapper.toDevices(mappedSnapshot); + const entities = GoveeBleMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.serialNumber).toEqual('AA:BB:CC:DD:EE:FF'); + expect(devices[0].integrationDomain).toEqual('govee_ble'); + expect(devices[0].manufacturer).toEqual('Govee'); + expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_temperature') && entityArg.state === 21.6)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.name === 'Motion')).toBeTrue(); +}); + +tap.test('exposes Govee BLE read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GoveeBleIntegration(); + const alias = new HomeAssistantGoveeBleIntegration(); + expect(alias instanceof GoveeBleIntegration).toBeTrue(); + expect(alias.domain).toEqual('govee_ble'); + expect(integration.status).toEqual('read-only-runtime'); + expect(goveeBleProfile.metadata.configFlow).toEqual(true); + expect(goveeBleProfile.metadata.requirements).toEqual(['govee-ble==1.2.0']); + + const runtime = await integration.setup({ name: 'Govee H5075', rawData }, {}); + const status = await runtime.callService!({ domain: 'govee_ble', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'govee_ble', service: 'refresh', target: {} }); + const snapshot = status.data as IGoveeBleSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Govee H5075'); + + const command = await runtime.callService!({ domain: 'govee_ble', 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/govee_light_local/test.govee_light_local.node.ts b/test/govee_light_local/test.govee_light_local.node.ts new file mode 100644 index 0000000..378eb6d --- /dev/null +++ b/test/govee_light_local/test.govee_light_local.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GoveeLightLocalClient, GoveeLightLocalConfigFlow, GoveeLightLocalIntegration, GoveeLightLocalMapper, HomeAssistantGoveeLightLocalIntegration, createGoveeLightLocalDiscoveryDescriptor, goveeLightLocalProfile, type IGoveeLightLocalSnapshot, type TGoveeLightLocalRawData } from '../../ts/integrations/govee_light_local/index.js'; + +const rawData: TGoveeLightLocalRawData = { + devices: [ + { + fingerprint: 'GOVEE-LAN-001', + sku: 'H6008', + name: 'Desk Strip', + on: true, + brightness: 75, + temperature_color: 3200, + rgb_color: [255, 128, 0], + capabilities: { + features: 'COLOR_RGB COLOR_KELVIN_TEMPERATURE BRIGHTNESS SCENES', + scenes: { + sunrise: 1, + movie: 2, + }, + }, + }, + ], +}; + +tap.test('matches manual Govee light local candidates and creates config flow output', async () => { + const descriptor = createGoveeLightLocalDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'govee_light_local-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'GOVEE-LAN-001', name: 'Desk Strip', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('govee_light_local'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GoveeLightLocalConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Desk Strip'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Govee light local raw snapshots to runtime devices and entities', async () => { + const client = new GoveeLightLocalClient({ name: 'Desk Strip', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GoveeLightLocalMapper.toSnapshotFromRaw({ name: 'Desk Strip' }, rawData); + const devices = GoveeLightLocalMapper.toDevices(mappedSnapshot); + const entities = GoveeLightLocalMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.serialNumber).toEqual('GOVEE-LAN-001'); + expect(devices[0].integrationDomain).toEqual('govee_light_local'); + expect(devices[0].manufacturer).toEqual('Govee'); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === true)).toBeTrue(); + expect(entities[0].attributes?.brightnessPercent).toEqual(75); +}); + +tap.test('exposes Govee light local runtime, HA alias, and unsupported control without executor', async () => { + const integration = new GoveeLightLocalIntegration(); + const alias = new HomeAssistantGoveeLightLocalIntegration(); + expect(alias instanceof GoveeLightLocalIntegration).toBeTrue(); + expect(alias.domain).toEqual('govee_light_local'); + expect(integration.status).toEqual('control-runtime'); + expect(goveeLightLocalProfile.metadata.configFlow).toEqual(true); + expect(goveeLightLocalProfile.metadata.dependencies).toEqual(['network']); + + const runtime = await integration.setup({ name: 'Desk Strip', rawData }, {}); + const status = await runtime.callService!({ domain: 'govee_light_local', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'govee_light_local', service: 'refresh', target: {} }); + const snapshot = status.data as IGoveeLightLocalSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Desk Strip'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 128 } }); + 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/gpsd/test.gpsd.node.ts b/test/gpsd/test.gpsd.node.ts new file mode 100644 index 0000000..97cb5f5 --- /dev/null +++ b/test/gpsd/test.gpsd.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GpsdClient, GpsdConfigFlow, GpsdIntegration, GpsdMapper, HomeAssistantGpsdIntegration, createGpsdDiscoveryDescriptor, gpsdProfile, type IGpsdSnapshot, type TGpsdRawData } from '../../ts/integrations/gpsd/index.js'; + +const rawData: TGpsdRawData = { + mode: 3, + lat: 52.52, + lon: 13.405, + alt: 35.4, + time: '2026-05-11T10:00:00Z', + speed: 1.4, + climb: 0.1, + satellites: [ + { used: true }, + { used: false }, + { used: true }, + ], +}; + +tap.test('matches manual GPSD candidates and creates config flow output', async () => { + const descriptor = createGpsdDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gpsd-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '127.0.0.1', port: 2947, name: 'GPS 127.0.0.1', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gpsd'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GpsdConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('127.0.0.1'); + expect(done.config?.port).toEqual(2947); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps GPSD raw snapshots to runtime devices and entities', async () => { + const client = new GpsdClient({ host: '127.0.0.1', port: 2947, rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GpsdMapper.toSnapshotFromRaw({ host: '127.0.0.1', port: 2947 }, rawData); + const devices = GpsdMapper.toDevices(mappedSnapshot); + const entities = GpsdMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.port).toEqual(2947); + expect(devices[0].integrationDomain).toEqual('gpsd'); + expect(devices[0].manufacturer).toEqual('GPSD'); + expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_mode') && entityArg.state === '3d_fix')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_used_satellites') && entityArg.state === 2)).toBeTrue(); +}); + +tap.test('exposes GPSD read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GpsdIntegration(); + const alias = new HomeAssistantGpsdIntegration(); + expect(alias instanceof GpsdIntegration).toBeTrue(); + expect(alias.domain).toEqual('gpsd'); + expect(integration.status).toEqual('read-only-runtime'); + expect(gpsdProfile.metadata.configFlow).toEqual(true); + expect(gpsdProfile.metadata.requirements).toEqual(['gps3==0.33.3']); + + const runtime = await integration.setup({ host: '127.0.0.1', port: 2947, rawData }, {}); + const status = await runtime.callService!({ domain: 'gpsd', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gpsd', service: 'refresh', target: {} }); + const snapshot = status.data as IGpsdSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('GPS'); + + const command = await runtime.callService!({ domain: 'gpsd', 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/graphite/test.graphite.node.ts b/test/graphite/test.graphite.node.ts new file mode 100644 index 0000000..bd5e3d3 --- /dev/null +++ b/test/graphite/test.graphite.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GraphiteClient, GraphiteConfigFlow, GraphiteIntegration, GraphiteMapper, HomeAssistantGraphiteIntegration, createGraphiteDiscoveryDescriptor, graphiteProfile, type IGraphiteSnapshot, type TGraphiteRawData } from '../../ts/integrations/graphite/index.js'; + +const rawData: TGraphiteRawData = { + host: '127.0.0.1', + port: 2003, + protocol: 'tcp', + prefix: 'ha', + metrics: [ + 'ha.sensor.temperature.state 21.5 1710000000', + ], +}; + +tap.test('matches manual Graphite candidates and creates config flow output', async () => { + const descriptor = createGraphiteDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'graphite-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '127.0.0.1', port: 2003, name: 'Graphite 127.0.0.1', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('graphite'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GraphiteConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('127.0.0.1'); + expect(done.config?.port).toEqual(2003); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Graphite raw snapshots to runtime devices and entities', async () => { + const client = new GraphiteClient({ host: '127.0.0.1', port: 2003, rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GraphiteMapper.toSnapshotFromRaw({ host: '127.0.0.1', port: 2003 }, rawData); + const devices = GraphiteMapper.toDevices(mappedSnapshot); + const entities = GraphiteMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.port).toEqual(2003); + expect(devices[0].integrationDomain).toEqual('graphite'); + expect(devices[0].manufacturer).toEqual('Graphite'); + expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_metric_lines') && entityArg.state === 1)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.name === 'Configured')).toBeTrue(); +}); + +tap.test('exposes Graphite runtime, HA alias, and unsupported metric send without executor', async () => { + const integration = new GraphiteIntegration(); + const alias = new HomeAssistantGraphiteIntegration(); + expect(alias instanceof GraphiteIntegration).toBeTrue(); + expect(alias.domain).toEqual('graphite'); + expect(integration.status).toEqual('control-runtime'); + expect(graphiteProfile.metadata.configFlow).toEqual(false); + expect(graphiteProfile.metadata.qualityScale).toEqual('legacy'); + + const runtime = await integration.setup({ host: '127.0.0.1', port: 2003, rawData }, {}); + const status = await runtime.callService!({ domain: 'graphite', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'graphite', service: 'refresh', target: {} }); + const snapshot = status.data as IGraphiteSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Graphite 127.0.0.1:2003'); + + const command = await runtime.callService!({ domain: 'graphite', service: 'send_metric', target: {}, data: { metric: 'ha.test 1 1710000000' } }); + 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/gree/test.gree.node.ts b/test/gree/test.gree.node.ts new file mode 100644 index 0000000..0057c53 --- /dev/null +++ b/test/gree/test.gree.node.ts @@ -0,0 +1,84 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GreeClient, GreeConfigFlow, GreeIntegration, GreeMapper, HomeAssistantGreeIntegration, createGreeDiscoveryDescriptor, greeProfile, type IGreeSnapshot, type TGreeRawData } from '../../ts/integrations/gree/index.js'; + +const rawData: TGreeRawData = { + device_info: { + name: 'Bedroom AC', + ip: '192.168.1.70', + port: 7000, + mac: 'AA:BB:CC:DD:EE:70', + }, + raw_properties: { + Pow: 1, + Mod: 1, + SetTem: 23, + TemSen: 22, + WdSpd: 3, + Lig: 1, + Quiet: 0, + Air: 1, + Blo: 0, + Health: 1, + Tur: 0, + SwhSlp: 0, + }, +}; + +tap.test('matches manual Gree candidates and creates config flow output', async () => { + const descriptor = createGreeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gree-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.168.1.70', port: 7000, name: 'Bedroom AC', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gree'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GreeConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.70'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Gree raw snapshots to runtime devices and entities', async () => { + const client = new GreeClient({ host: '192.168.1.70', port: 7000, rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GreeMapper.toSnapshotFromRaw({ host: '192.168.1.70', port: 7000 }, rawData); + const devices = GreeMapper.toDevices(mappedSnapshot); + const entities = GreeMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.serialNumber).toEqual('AA:BB:CC:DD:EE:70'); + expect(devices[0].integrationDomain).toEqual('gree'); + expect(devices[0].manufacturer).toEqual('Gree'); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'cool')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.name === 'Panel light' && entityArg.state === true)).toBeTrue(); +}); + +tap.test('exposes Gree runtime, HA alias, and unsupported control without executor', async () => { + const integration = new GreeIntegration(); + const alias = new HomeAssistantGreeIntegration(); + expect(alias instanceof GreeIntegration).toBeTrue(); + expect(alias.domain).toEqual('gree'); + expect(integration.status).toEqual('control-runtime'); + expect(greeProfile.metadata.configFlow).toEqual(true); + expect(greeProfile.metadata.requirements).toEqual(['greeclimate==2.1.1']); + + const runtime = await integration.setup({ host: '192.168.1.70', port: 7000, rawData }, {}); + const status = await runtime.callService!({ domain: 'gree', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gree', service: 'refresh', target: {} }); + const snapshot = status.data as IGreeSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Bedroom AC'); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 24 } }); + 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/greeneye_monitor/test.greeneye_monitor.node.ts b/test/greeneye_monitor/test.greeneye_monitor.node.ts new file mode 100644 index 0000000..40af603 --- /dev/null +++ b/test/greeneye_monitor/test.greeneye_monitor.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GreeneyeMonitorClient, GreeneyeMonitorConfigFlow, GreeneyeMonitorIntegration, GreeneyeMonitorMapper, HomeAssistantGreeneyeMonitorIntegration, createGreeneyeMonitorDiscoveryDescriptor, greeneyeMonitorProfile, type IGreeneyeMonitorSnapshot, type TGreeneyeMonitorRawData } from '../../ts/integrations/greeneye_monitor/index.js'; + +const rawData: TGreeneyeMonitorRawData = { + device: { + id: 'gem-00012345', + name: 'GEM 00012345', + manufacturer: 'Brultech', + model: 'GreenEye Monitor', + serialNumber: '00012345', + }, + entities: [ + { id: 'channel_1_power', name: 'Channel 1 Power', platform: 'sensor', state: 512, unit: 'W', deviceClass: 'power', attributes: { watt_seconds: 123456 } }, + { id: 'voltage_1', name: 'Voltage 1', platform: 'sensor', state: 120.4, unit: 'V', deviceClass: 'voltage' }, + { id: 'temperature_1', name: 'Temperature 1', platform: 'sensor', state: 21.5, unit: 'C', deviceClass: 'temperature' }, + { id: 'pulse_counter_1', name: 'Water Meter', platform: 'sensor', state: 12.3, unit: 'L/min', attributes: { pulses: 42 } }, + ], + packet: { + channels: 48, + pulseCounters: 4, + }, +}; + +tap.test('matches manual GreenEye Monitor candidates and creates config flow output', async () => { + const descriptor = createGreeneyeMonitorDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'greeneye_monitor-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'gem-00012345', name: 'GEM 00012345', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('greeneye_monitor'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GreeneyeMonitorConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('GEM 00012345'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps GreenEye Monitor raw snapshots to runtime devices and entities', async () => { + const client = new GreeneyeMonitorClient({ name: 'GreenEye Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GreeneyeMonitorMapper.toSnapshotFromRaw({ name: 'GreenEye Runtime' }, rawData); + const devices = GreeneyeMonitorMapper.toDevices(mappedSnapshot); + const entities = GreeneyeMonitorMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('greeneye_monitor'); + expect(devices[0].manufacturer).toEqual('Brultech'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.gem_00012345_channel_1_power')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.gem_00012345_temperature_1')).toBeTrue(); +}); + +tap.test('exposes GreenEye Monitor read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GreeneyeMonitorIntegration(); + const alias = new HomeAssistantGreeneyeMonitorIntegration(); + expect(alias instanceof GreeneyeMonitorIntegration).toBeTrue(); + expect(alias.domain).toEqual('greeneye_monitor'); + expect(integration.status).toEqual('read-only-runtime'); + expect(greeneyeMonitorProfile.metadata.configFlow).toEqual(false); + expect(greeneyeMonitorProfile.metadata.qualityScale).toEqual('legacy'); + expect(greeneyeMonitorProfile.metadata.requirements).toEqual(['greeneye_monitor==3.0.3']); + + const runtime = await integration.setup({ name: 'GreenEye Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'greeneye_monitor', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'greeneye_monitor', service: 'refresh', target: {} }); + const snapshot = status.data as IGreeneyeMonitorSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('GEM 00012345'); + + const command = await runtime.callService!({ domain: 'greeneye_monitor', 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/greenwave/test.greenwave.node.ts b/test/greenwave/test.greenwave.node.ts new file mode 100644 index 0000000..b3562c8 --- /dev/null +++ b/test/greenwave/test.greenwave.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GreenwaveClient, GreenwaveConfigFlow, GreenwaveIntegration, GreenwaveMapper, HomeAssistantGreenwaveIntegration, createGreenwaveDiscoveryDescriptor, greenwaveProfile, type IGreenwaveSnapshot, type TGreenwaveRawData } from '../../ts/integrations/greenwave/index.js'; + +const rawData: TGreenwaveRawData = { + device: { + id: 'greenwave-gateway', + name: 'Greenwave Gateway', + manufacturer: 'Greenwave Reality', + model: 'TCP Connected gateway', + host: '192.0.2.30', + }, + entities: [ + { id: 'kitchen_lamp', name: 'Kitchen Lamp', platform: 'light', state: true, writable: true, attributes: { did: 3, brightness: 191, available: true } }, + { id: 'hallway_lamp', name: 'Hallway Lamp', platform: 'light', state: false, writable: true, attributes: { did: 4, brightness: 0, available: true } }, + ], + bulbs: { + '3': { did: '3', name: 'Kitchen Lamp', state: '1', level: '75' }, + '4': { did: '4', name: 'Hallway Lamp', state: '0', level: '0' }, + }, +}; + +tap.test('matches manual Greenwave candidates and creates config flow output', async () => { + const descriptor = createGreenwaveDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'greenwave-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.30', name: 'Greenwave Gateway', metadata: { rawData, version: 3 } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('greenwave'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GreenwaveConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.30'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Greenwave raw snapshots to runtime devices and entities', async () => { + const client = new GreenwaveClient({ name: 'Greenwave Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GreenwaveMapper.toSnapshotFromRaw({ name: 'Greenwave Runtime' }, rawData); + const devices = GreenwaveMapper.toDevices(mappedSnapshot); + const entities = GreenwaveMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('greenwave'); + expect(devices[0].manufacturer).toEqual('Greenwave Reality'); + expect(entities.some((entityArg) => entityArg.id === 'light.greenwave_gateway_kitchen_lamp')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.greenwave_gateway_hallway_lamp')).toBeTrue(); +}); + +tap.test('exposes Greenwave read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GreenwaveIntegration(); + const alias = new HomeAssistantGreenwaveIntegration(); + expect(alias instanceof GreenwaveIntegration).toBeTrue(); + expect(alias.domain).toEqual('greenwave'); + expect(integration.status).toEqual('read-only-runtime'); + expect(greenwaveProfile.metadata.configFlow).toEqual(false); + expect(greenwaveProfile.metadata.qualityScale).toEqual('legacy'); + expect(greenwaveProfile.metadata.requirements).toEqual(['greenwavereality==0.5.1']); + + const runtime = await integration.setup({ name: 'Greenwave Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'greenwave', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'greenwave', service: 'refresh', target: {} }); + const snapshot = status.data as IGreenwaveSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Greenwave Gateway'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.greenwave_gateway_kitchen_lamp' }, data: { brightness: 191 } }); + 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/gtfs/test.gtfs.node.ts b/test/gtfs/test.gtfs.node.ts new file mode 100644 index 0000000..d554995 --- /dev/null +++ b/test/gtfs/test.gtfs.node.ts @@ -0,0 +1,93 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GtfsClient, GtfsConfigFlow, GtfsIntegration, GtfsMapper, HomeAssistantGtfsIntegration, createGtfsDiscoveryDescriptor, gtfsProfile, type IGtfsSnapshot, type TGtfsRawData } from '../../ts/integrations/gtfs/index.js'; + +const rawData: TGtfsRawData = { + device: { + id: 'gtfs-downtown-route', + name: 'GTFS Downtown Route', + manufacturer: 'GTFS', + model: 'Transit schedule feed', + }, + entities: [ + { + id: 'next_departure', + name: 'Next Departure', + platform: 'sensor', + state: '2026-05-11T14:35:00Z', + deviceClass: 'timestamp', + attributes: { + arrival: '2026-05-11T14:58:00Z', + day: 'today', + destination: 'DOWNTOWN', + origin: 'CENTRAL', + route_id: '10', + trip_id: 'weekday-10-1435', + }, + }, + ], + query: { + data: 'city.zip', + destination: 'DOWNTOWN', + origin: 'CENTRAL', + }, +}; + +tap.test('matches manual GTFS candidates and creates config flow output', async () => { + const descriptor = createGtfsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gtfs-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'gtfs-downtown-route', name: 'Downtown GTFS', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('gtfs'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GtfsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Downtown GTFS'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps GTFS raw snapshots to runtime devices and entities', async () => { + const client = new GtfsClient({ name: 'GTFS Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GtfsMapper.toSnapshotFromRaw({ name: 'GTFS Runtime' }, rawData); + const devices = GtfsMapper.toDevices(mappedSnapshot); + const entities = GtfsMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('gtfs'); + expect(devices[0].manufacturer).toEqual('GTFS'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.gtfs_downtown_route_next_departure')).toBeTrue(); + expect(entities[0].attributes?.deviceClass).toEqual('timestamp'); +}); + +tap.test('exposes GTFS read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GtfsIntegration(); + const alias = new HomeAssistantGtfsIntegration(); + expect(alias instanceof GtfsIntegration).toBeTrue(); + expect(alias.domain).toEqual('gtfs'); + expect(integration.status).toEqual('read-only-runtime'); + expect(gtfsProfile.metadata.configFlow).toEqual(false); + expect(gtfsProfile.metadata.qualityScale).toEqual('legacy'); + expect(gtfsProfile.metadata.requirements).toEqual(['pygtfs==0.1.9']); + + const runtime = await integration.setup({ name: 'GTFS Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'gtfs', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'gtfs', service: 'refresh', target: {} }); + const snapshot = status.data as IGtfsSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('GTFS Downtown Route'); + + const command = await runtime.callService!({ domain: 'gtfs', 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/guardian/test.guardian.node.ts b/test/guardian/test.guardian.node.ts new file mode 100644 index 0000000..8fa8f33 --- /dev/null +++ b/test/guardian/test.guardian.node.ts @@ -0,0 +1,87 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GuardianClient, GuardianConfigFlow, GuardianIntegration, GuardianMapper, HomeAssistantGuardianIntegration, createGuardianDiscoveryDescriptor, guardianProfile, type IGuardianSnapshot, type TGuardianRawData } from '../../ts/integrations/guardian/index.js'; + +const rawData: TGuardianRawData = { + device: { + id: 'guardian-5410ec688bcf', + name: 'Guardian Valve Controller 5410EC688BCF', + manufacturer: 'Elexa', + model: 'Guardian valve controller', + serialNumber: '5410EC688BCF', + host: '192.0.2.77', + port: 7777, + }, + entities: [ + { id: 'leak_detected', name: 'Leak Detected', platform: 'binary_sensor', state: false, deviceClass: 'moisture' }, + { id: 'temperature', name: 'Temperature', platform: 'sensor', state: 68.2, unit: 'F', deviceClass: 'temperature' }, + { id: 'average_current', name: 'Average Current', platform: 'sensor', state: 124, unit: 'mA', deviceClass: 'current' }, + { id: 'valve', name: 'Valve', platform: 'switch', state: true, writable: true, attributes: { valveState: 'open', travel_count: 4 } }, + { id: 'onboard_ap', name: 'Onboard Access Point', platform: 'switch', state: false, writable: true, attributes: { connected_clients: 0 } }, + { id: 'reboot', name: 'Reboot', platform: 'button', state: 'idle', writable: true }, + ], + apis: { + valve_status: { state: 'open', average_current: 124, travel_count: 4 }, + system_diagnostics: { firmware: '1.2.3', uptime: 720 }, + }, +}; + +tap.test('matches manual Guardian candidates and creates config flow output', async () => { + const descriptor = createGuardianDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'guardian-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.77', name: 'Guardian 5410EC688BCF', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('guardian'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new GuardianConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.77'); + expect(done.config?.port).toEqual(7777); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Guardian raw snapshots to runtime devices and entities', async () => { + const client = new GuardianClient({ name: 'Guardian Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = GuardianMapper.toSnapshotFromRaw({ name: 'Guardian Runtime' }, rawData); + const devices = GuardianMapper.toDevices(mappedSnapshot); + const entities = GuardianMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('guardian'); + expect(devices[0].manufacturer).toEqual('Elexa'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.guardian_valve_controller_5410ec688bcf_leak_detected')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.guardian_valve_controller_5410ec688bcf_valve')).toBeTrue(); +}); + +tap.test('exposes Guardian read-only runtime, HA alias, and unsupported control', async () => { + const integration = new GuardianIntegration(); + const alias = new HomeAssistantGuardianIntegration(); + expect(alias instanceof GuardianIntegration).toBeTrue(); + expect(alias.domain).toEqual('guardian'); + expect(integration.status).toEqual('read-only-runtime'); + expect(guardianProfile.metadata.configFlow).toEqual(true); + expect(guardianProfile.metadata.qualityScale).toEqual(undefined); + expect(guardianProfile.metadata.requirements).toEqual(['aioguardian==2026.01.1']); + + const runtime = await integration.setup({ name: 'Guardian Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'guardian', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'guardian', service: 'refresh', target: {} }); + const snapshot = status.data as IGuardianSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Guardian Valve Controller 5410EC688BCF'); + + const command = await runtime.callService!({ domain: 'valve', service: 'open_valve', target: { entityId: 'switch.guardian_valve_controller_5410ec688bcf_valve' } }); + 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/hassio/test.hassio.node.ts b/test/hassio/test.hassio.node.ts new file mode 100644 index 0000000..a3f40e0 --- /dev/null +++ b/test/hassio/test.hassio.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HassioClient, HassioConfigFlow, HassioIntegration, HassioMapper, HomeAssistantHassioIntegration, createHassioDiscoveryDescriptor, hassioProfile, type IHassioSnapshot, type THassioRawData } from '../../ts/integrations/hassio/index.js'; + +const rawData: THassioRawData = { + device: { + id: 'home-assistant-supervisor', + name: 'Home Assistant Supervisor', + manufacturer: 'Home Assistant', + model: 'Home Assistant Supervisor', + }, + entities: [ + { id: 'supervisor_version', name: 'Supervisor Version', platform: 'sensor', state: '2026.05.1', attributes: { channel: 'stable', healthy: true, supported: true } }, + { id: 'host_disk_free', name: 'Host Disk Free', platform: 'sensor', state: 128.4, unit: 'GB', deviceClass: 'data_size' }, + { id: 'core_cpu_percent', name: 'Core CPU Percent', platform: 'sensor', state: 8.2, unit: '%' }, + { id: 'core_ssh_state', name: 'Terminal Add-on', platform: 'binary_sensor', state: true, deviceClass: 'running', attributes: { slug: 'core_ssh', version: '9.15.0' } }, + { id: 'core_ssh_switch', name: 'Terminal Add-on Switch', platform: 'switch', state: true, writable: true, attributes: { slug: 'core_ssh' } }, + { id: 'core_update', name: 'Home Assistant Core Update', platform: 'update', state: false, writable: true, attributes: { installed_version: '2026.5.0', latest_version: '2026.5.0' } }, + ], + supervisor: { + healthy: true, + supported: true, + version: '2026.05.1', + }, +}; + +tap.test('matches manual Hassio candidates and creates config flow output', async () => { + const descriptor = createHassioDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hassio-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'home-assistant-supervisor', name: 'Home Assistant Supervisor', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hassio'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HassioConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Home Assistant Supervisor'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hassio raw snapshots to runtime devices and entities', async () => { + const client = new HassioClient({ name: 'Hassio Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HassioMapper.toSnapshotFromRaw({ name: 'Hassio Runtime' }, rawData); + const devices = HassioMapper.toDevices(mappedSnapshot); + const entities = HassioMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hassio'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.home_assistant_supervisor_supervisor_version')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'update.home_assistant_supervisor_core_update')).toBeTrue(); +}); + +tap.test('exposes Hassio read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HassioIntegration(); + const alias = new HomeAssistantHassioIntegration(); + expect(alias instanceof HassioIntegration).toBeTrue(); + expect(alias.domain).toEqual('hassio'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hassioProfile.metadata.configFlow).toEqual(false); + expect(hassioProfile.metadata.qualityScale).toEqual('internal'); + expect(hassioProfile.metadata.dependencies).toEqual(['http', 'repairs']); + expect(hassioProfile.metadata.requirements).toEqual(['aiohasupervisor==0.4.3']); + + const runtime = await integration.setup({ name: 'Hassio Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'hassio', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hassio', service: 'refresh', target: {} }); + const snapshot = status.data as IHassioSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Home Assistant Supervisor'); + + const command = await runtime.callService!({ domain: 'hassio', service: 'addon_start', target: {}, data: { addon: 'core_ssh' } }); + 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/hdfury/test.hdfury.node.ts b/test/hdfury/test.hdfury.node.ts new file mode 100644 index 0000000..c408c59 --- /dev/null +++ b/test/hdfury/test.hdfury.node.ts @@ -0,0 +1,86 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HdfuryClient, HdfuryConfigFlow, HdfuryIntegration, HdfuryMapper, HomeAssistantHdfuryIntegration, createHdfuryDiscoveryDescriptor, hdfuryProfile, type IHdfurySnapshot, type THdfuryRawData } from '../../ts/integrations/hdfury/index.js'; + +const rawData: THdfuryRawData = { + board: { + hostname: 'vrroom-lab', + serial: 'HDF123456', + version: 'FW: 1.23', + pcbv: 'A1', + }, + info: { + TX0: '4K60', + AUDOUT: 'eARC', + portseltx0: '1', + opmode: '2', + }, + config: { + macaddr: '00:11:22:33:44:55', + cec: '1', + oled: '0', + oledfade: '15', + }, +}; + +tap.test('matches manual HDFury candidates and creates config flow output', async () => { + const descriptor = createHdfuryDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hdfury-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: 'vrroom.local', name: 'HDFury VRROOM', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hdfury'); + expect(result.candidate?.port).toEqual(80); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HdfuryConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('vrroom.local'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps HDFury raw snapshots to runtime devices and entities', async () => { + const client = new HdfuryClient({ host: 'vrroom.local', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HdfuryMapper.toSnapshotFromRaw({ host: 'vrroom.local' }, rawData); + const devices = HdfuryMapper.toDevices(mappedSnapshot); + const entities = HdfuryMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.device.serialNumber).toEqual('HDF123456'); + expect(devices[0].integrationDomain).toEqual('hdfury'); + expect(devices[0].manufacturer).toEqual('HDFury'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.vrroom_lab_tx0')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.vrroom_lab_cec')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'number.vrroom_lab_oledfade')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'select.vrroom_lab_opmode')).toBeTrue(); +}); + +tap.test('exposes HDFury read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HdfuryIntegration(); + const alias = new HomeAssistantHdfuryIntegration(); + expect(alias instanceof HdfuryIntegration).toBeTrue(); + expect(alias.domain).toEqual('hdfury'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hdfuryProfile.metadata.configFlow).toEqual(true); + expect(hdfuryProfile.metadata.qualityScale).toEqual('platinum'); + expect(hdfuryProfile.metadata.requirements).toEqual(['hdfury==1.6.0']); + + const runtime = await integration.setup({ host: 'vrroom.local', rawData }, {}); + const status = await runtime.callService!({ domain: 'hdfury', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hdfury', service: 'refresh', target: {} }); + const snapshot = status.data as IHdfurySnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('vrroom-lab'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.vrroom_lab_cec' } }); + 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/hdmi_cec/test.hdmi_cec.node.ts b/test/hdmi_cec/test.hdmi_cec.node.ts new file mode 100644 index 0000000..8197ab1 --- /dev/null +++ b/test/hdmi_cec/test.hdmi_cec.node.ts @@ -0,0 +1,90 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HdmiCecClient, HdmiCecConfigFlow, HdmiCecIntegration, HdmiCecMapper, HomeAssistantHdmiCecIntegration, createHdmiCecDiscoveryDescriptor, hdmiCecProfile, type IHdmiCecSnapshot, type THdmiCecRawData } from '../../ts/integrations/hdmi_cec/index.js'; + +const rawData: THdmiCecRawData = { + name: 'Living Room CEC Bus', + devices: [ + { + logicalAddress: 4, + physicalAddress: '1.0.0.0', + typeId: 4, + typeName: 'Playback', + vendor: 'Sony', + vendorId: 43775, + osdName: 'Blu-ray', + powerStatus: 1, + status: 'playing', + platform: 'media_player', + }, + { + logicalAddress: 0, + physicalAddress: '0.0.0.0', + typeId: 0, + typeName: 'TV', + vendor: 'LG', + osdName: 'TV', + powerStatus: 0, + platform: 'switch', + }, + ], +}; + +tap.test('matches manual HDMI-CEC candidates and creates config flow output', async () => { + const descriptor = createHdmiCecDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hdmi_cec-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'living-room-cec', name: 'HDMI-CEC', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hdmi_cec'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HdmiCecConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('HDMI-CEC'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps HDMI-CEC raw snapshots to runtime devices and entities', async () => { + const client = new HdmiCecClient({ name: 'Living Room CEC Bus', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HdmiCecMapper.toSnapshotFromRaw({ name: 'Living Room CEC Bus' }, rawData); + const devices = HdmiCecMapper.toDevices(mappedSnapshot); + const entities = HdmiCecMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('hdmi_cec'); + expect(devices[0].manufacturer).toEqual('HDMI-CEC'); + expect(entities.some((entityArg) => entityArg.id === 'media_player.living_room_cec_bus_hdmi_4')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.living_room_cec_bus_hdmi_0')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'media_player.living_room_cec_bus_hdmi_4')?.state).toEqual('playing'); +}); + +tap.test('exposes HDMI-CEC read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HdmiCecIntegration(); + const alias = new HomeAssistantHdmiCecIntegration(); + expect(alias instanceof HdmiCecIntegration).toBeTrue(); + expect(alias.domain).toEqual('hdmi_cec'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hdmiCecProfile.metadata.configFlow).toEqual(false); + expect(hdmiCecProfile.metadata.qualityScale).toEqual('legacy'); + expect(hdmiCecProfile.metadata.requirements).toEqual(['pyCEC==0.5.2']); + + const runtime = await integration.setup({ name: 'Living Room CEC Bus', rawData }, {}); + const status = await runtime.callService!({ domain: 'hdmi_cec', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hdmi_cec', service: 'refresh', target: {} }); + const snapshot = status.data as IHdmiCecSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.entities()).length).toEqual(2); + + const command = await runtime.callService!({ domain: 'hdmi_cec', service: 'send_command', target: {}, data: { raw: '10:36' } }); + 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/heatmiser/test.heatmiser.node.ts b/test/heatmiser/test.heatmiser.node.ts new file mode 100644 index 0000000..e022cf7 --- /dev/null +++ b/test/heatmiser/test.heatmiser.node.ts @@ -0,0 +1,84 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HeatmiserClient, HeatmiserConfigFlow, HeatmiserIntegration, HeatmiserMapper, HomeAssistantHeatmiserIntegration, createHeatmiserDiscoveryDescriptor, heatmiserProfile, type IHeatmiserSnapshot, type THeatmiserRawData } from '../../ts/integrations/heatmiser/index.js'; + +const rawData: THeatmiserRawData = { + thermostats: [ + { + id: 1, + name: 'Hallway', + floorTemp: 21, + targetTemp: 22, + currentState: 1, + temperatureFormat: 'C', + }, + { + id: 2, + name: 'Bedroom', + floorTemp: 18, + targetTemp: 16, + currentState: 0, + temperatureFormat: 'C', + }, + ], +}; + +tap.test('matches manual Heatmiser candidates and creates config flow output', async () => { + const descriptor = createHeatmiserDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'heatmiser-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: 'heatmiser.local', port: 4242, name: 'Heatmiser UH1', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('heatmiser'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HeatmiserConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('heatmiser.local'); + expect(done.config?.port).toEqual(4242); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Heatmiser raw snapshots to runtime devices and entities', async () => { + const client = new HeatmiserClient({ host: 'heatmiser.local', port: 4242, rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HeatmiserMapper.toSnapshotFromRaw({ host: 'heatmiser.local', port: 4242 }, rawData); + const devices = HeatmiserMapper.toDevices(mappedSnapshot); + const entities = HeatmiserMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('heatmiser'); + expect(devices[0].manufacturer).toEqual('Heatmiser'); + expect(entities.some((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_2')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_2')?.state).toEqual('off'); +}); + +tap.test('exposes Heatmiser read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HeatmiserIntegration(); + const alias = new HomeAssistantHeatmiserIntegration(); + expect(alias instanceof HeatmiserIntegration).toBeTrue(); + expect(alias.domain).toEqual('heatmiser'); + expect(integration.status).toEqual('read-only-runtime'); + expect(heatmiserProfile.metadata.configFlow).toEqual(false); + expect(heatmiserProfile.metadata.qualityScale).toEqual('legacy'); + expect(heatmiserProfile.metadata.requirements).toEqual(['heatmiserV3==2.0.4']); + + const runtime = await integration.setup({ host: 'heatmiser.local', port: 4242, rawData }, {}); + const status = await runtime.callService!({ domain: 'heatmiser', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'heatmiser', service: 'refresh', target: {} }); + const snapshot = status.data as IHeatmiserSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.entities()).length).toEqual(2); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.heatmiser_thermostat_1' }, 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/test/hegel/test.hegel.node.ts b/test/hegel/test.hegel.node.ts new file mode 100644 index 0000000..ce5b01b --- /dev/null +++ b/test/hegel/test.hegel.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HegelClient, HegelConfigFlow, HegelIntegration, HegelMapper, HomeAssistantHegelIntegration, createHegelDiscoveryDescriptor, hegelProfile, type IHegelSnapshot, type THegelRawData } from '../../ts/integrations/hegel/index.js'; + +const rawData: THegelRawData = { + name: 'Living Room Hegel', + model: 'H390', + state: { + power: true, + volume: 35, + mute: false, + input: 10, + }, +}; + +tap.test('matches manual Hegel candidates and creates config flow output', async () => { + const descriptor = createHegelDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hegel-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: 'hegel.local', name: 'Hegel H390', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hegel'); + expect(result.candidate?.port).toEqual(50001); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HegelConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('hegel.local'); + expect(done.config?.port).toEqual(50001); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hegel raw snapshots to runtime devices and entities', async () => { + const client = new HegelClient({ host: 'hegel.local', model: 'H390', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HegelMapper.toSnapshotFromRaw({ host: 'hegel.local', model: 'H390' }, rawData); + const devices = HegelMapper.toDevices(mappedSnapshot); + const entities = HegelMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('hegel'); + expect(devices[0].manufacturer).toEqual('Hegel'); + expect(entities[0].id).toEqual('media_player.living_room_hegel_media_player'); + expect(entities[0].state).toEqual('on'); + expect(entities[0].attributes?.source).toEqual('Network'); + expect(entities[0].attributes?.volumeLevel).toEqual(0.35); +}); + +tap.test('exposes Hegel read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HegelIntegration(); + const alias = new HomeAssistantHegelIntegration(); + expect(alias instanceof HegelIntegration).toBeTrue(); + expect(alias.domain).toEqual('hegel'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hegelProfile.metadata.configFlow).toEqual(true); + expect(hegelProfile.metadata.qualityScale).toEqual('silver'); + expect(hegelProfile.metadata.requirements).toEqual(['hegel-ip-client==0.1.4']); + + const runtime = await integration.setup({ host: 'hegel.local', model: 'H390', rawData }, {}); + const status = await runtime.callService!({ domain: 'hegel', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hegel', service: 'refresh', target: {} }); + const snapshot = status.data as IHegelSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.entities())[0].platform).toEqual('media_player'); + + const command = await runtime.callService!({ domain: 'media_player', service: 'turn_off', target: { entityId: 'media_player.living_room_hegel_media_player' } }); + 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/hikvisioncam/test.hikvisioncam.node.ts b/test/hikvisioncam/test.hikvisioncam.node.ts new file mode 100644 index 0000000..d3b64fd --- /dev/null +++ b/test/hikvisioncam/test.hikvisioncam.node.ts @@ -0,0 +1,70 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HikvisioncamClient, HikvisioncamConfigFlow, HikvisioncamIntegration, HikvisioncamMapper, HomeAssistantHikvisioncamIntegration, createHikvisioncamDiscoveryDescriptor, hikvisioncamProfile, type IHikvisioncamSnapshot, type THikvisioncamRawData } from '../../ts/integrations/hikvisioncam/index.js'; + +const rawData: THikvisioncamRawData = { + id: 'front-door-camera', + name: 'Front Door Camera', + model: 'DS-2CD2042WD-I', + motionDetectionEnabled: true, +}; + +tap.test('matches manual Hikvision candidates and creates config flow output', async () => { + const descriptor = createHikvisioncamDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hikvisioncam-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: 'hikvision.local', name: 'Front Door Camera', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hikvisioncam'); + expect(result.candidate?.port).toEqual(80); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HikvisioncamConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: '12345' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('hikvision.local'); + expect(done.config?.port).toEqual(80); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hikvision raw snapshots to runtime devices and entities', async () => { + const client = new HikvisioncamClient({ host: 'hikvision.local', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HikvisioncamMapper.toSnapshotFromRaw({ host: 'hikvision.local' }, rawData); + const devices = HikvisioncamMapper.toDevices(mappedSnapshot); + const entities = HikvisioncamMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(devices[0].integrationDomain).toEqual('hikvisioncam'); + expect(devices[0].manufacturer).toEqual('Hikvision'); + expect(entities[0].id).toEqual('switch.front_door_camera_motion_detection'); + expect(entities[0].state).toEqual(true); +}); + +tap.test('exposes Hikvision read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HikvisioncamIntegration(); + const alias = new HomeAssistantHikvisioncamIntegration(); + expect(alias instanceof HikvisioncamIntegration).toBeTrue(); + expect(alias.domain).toEqual('hikvisioncam'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hikvisioncamProfile.metadata.configFlow).toEqual(false); + expect(hikvisioncamProfile.metadata.qualityScale).toEqual('legacy'); + expect(hikvisioncamProfile.metadata.requirements).toEqual(['hikvision==0.4']); + + const runtime = await integration.setup({ host: 'hikvision.local', rawData }, {}); + const status = await runtime.callService!({ domain: 'hikvisioncam', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hikvisioncam', service: 'refresh', target: {} }); + const snapshot = status.data as IHikvisioncamSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Front Door Camera'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.front_door_camera_motion_detection' } }); + 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/hisense_aehw4a1/test.hisense_aehw4a1.node.ts b/test/hisense_aehw4a1/test.hisense_aehw4a1.node.ts new file mode 100644 index 0000000..e248fcb --- /dev/null +++ b/test/hisense_aehw4a1/test.hisense_aehw4a1.node.ts @@ -0,0 +1,78 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HisenseAehw4a1Client, HisenseAehw4a1ConfigFlow, HisenseAehw4a1Integration, HisenseAehw4a1Mapper, HomeAssistantHisenseAehw4a1Integration, createHisenseAehw4a1DiscoveryDescriptor, hisenseAehw4a1Profile, type IHisenseAehw4a1Snapshot, type THisenseAehw4a1RawData } from '../../ts/integrations/hisense_aehw4a1/index.js'; + +const rawData: THisenseAehw4a1RawData = { + status: { + run_status: '1', + mode_status: '0010', + wind_status: '00000110', + up_down: '1', + left_right: '0', + temperature_Fahrenheit: '0', + indoor_temperature_status: '00010101', + indoor_temperature_setting: '00010011', + efficient: '0', + low_electricity: '0', + sleep_status: '0000000', + }, +}; + +tap.test('matches manual Hisense AEH-W4A1 candidates and creates config flow output', async () => { + const descriptor = createHisenseAehw4a1DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hisense_aehw4a1-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: '192.0.2.44', host: '192.0.2.44', name: 'Bedroom AC', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hisense_aehw4a1'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HisenseAehw4a1ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.44'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hisense AEH-W4A1 raw snapshots to runtime devices and entities', async () => { + const client = new HisenseAehw4a1Client({ name: 'Bedroom AC', host: '192.0.2.44', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HisenseAehw4a1Mapper.toSnapshotFromRaw({ name: 'Bedroom AC', host: '192.0.2.44' }, rawData); + const devices = HisenseAehw4a1Mapper.toDevices(mappedSnapshot); + const entities = HisenseAehw4a1Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hisense_aehw4a1'); + expect(devices[0].manufacturer).toEqual('Hisense'); + expect(entities.some((entityArg) => entityArg.id === 'climate.bedroom_ac_climate')).toBeTrue(); + expect(entities[0].attributes?.currentTemperature).toEqual(21); +}); + +tap.test('exposes Hisense AEH-W4A1 read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HisenseAehw4a1Integration(); + const alias = new HomeAssistantHisenseAehw4a1Integration(); + expect(alias instanceof HisenseAehw4a1Integration).toBeTrue(); + expect(alias.domain).toEqual('hisense_aehw4a1'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hisenseAehw4a1Profile.metadata.configFlow).toEqual(true); + expect(hisenseAehw4a1Profile.metadata.requirements).toEqual(['pyaehw4a1==0.3.9']); + expect(hisenseAehw4a1Profile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ name: 'Bedroom AC', host: '192.0.2.44', rawData }, {}); + const status = await runtime.callService!({ domain: 'hisense_aehw4a1', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hisense_aehw4a1', service: 'refresh', target: {} }); + const snapshot = status.data as IHisenseAehw4a1Snapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Bedroom AC'); + + const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.bedroom_ac_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/test/hitron_coda/test.hitron_coda.node.ts b/test/hitron_coda/test.hitron_coda.node.ts new file mode 100644 index 0000000..305e0f3 --- /dev/null +++ b/test/hitron_coda/test.hitron_coda.node.ts @@ -0,0 +1,68 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HitronCodaClient, HitronCodaConfigFlow, HitronCodaIntegration, HitronCodaMapper, HomeAssistantHitronCodaIntegration, createHitronCodaDiscoveryDescriptor, hitronCodaProfile, type IHitronCodaSnapshot, type THitronCodaRawData } from '../../ts/integrations/hitron_coda/index.js'; + +const rawData: THitronCodaRawData = [ + { macAddr: 'aa:bb:cc:dd:ee:01', hostName: 'phone', ipAddr: '192.0.2.10', type: 'wifi' }, + { macAddr: 'aa:bb:cc:dd:ee:02', hostName: 'laptop', ipAddr: '192.0.2.11', type: 'ethernet' }, +]; + +tap.test('matches manual Hitron CODA candidates and creates config flow output', async () => { + const descriptor = createHitronCodaDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hitron_coda-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.1', name: 'CODA Router', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hitron_coda'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HitronCodaConfigFlow().start(result.candidate!, {})).submit!({ username: 'cusadmin', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.1'); + expect(done.config?.username).toEqual('cusadmin'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hitron CODA raw snapshots to runtime devices and entities', async () => { + const client = new HitronCodaClient({ name: 'CODA Router', host: '192.0.2.1', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HitronCodaMapper.toSnapshotFromRaw({ name: 'CODA Router', host: '192.0.2.1' }, rawData); + const devices = HitronCodaMapper.toDevices(mappedSnapshot); + const entities = HitronCodaMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hitron_coda'); + expect(devices[0].manufacturer).toEqual('Hitron'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.coda_router_connected_devices')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.coda_router_client_aa_bb_cc_dd_ee_01')).toBeTrue(); +}); + +tap.test('exposes Hitron CODA read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HitronCodaIntegration(); + const alias = new HomeAssistantHitronCodaIntegration(); + expect(alias instanceof HitronCodaIntegration).toBeTrue(); + expect(alias.domain).toEqual('hitron_coda'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hitronCodaProfile.metadata.configFlow).toEqual(false); + expect(hitronCodaProfile.metadata.qualityScale).toEqual('legacy'); + expect(hitronCodaProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: 'CODA Router', host: '192.0.2.1', rawData }, {}); + const status = await runtime.callService!({ domain: 'hitron_coda', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hitron_coda', service: 'refresh', target: {} }); + const snapshot = status.data as IHitronCodaSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('CODA Router'); + + const command = await runtime.callService!({ domain: 'hitron_coda', 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/hlk_sw16/test.hlk_sw16.node.ts b/test/hlk_sw16/test.hlk_sw16.node.ts new file mode 100644 index 0000000..4022533 --- /dev/null +++ b/test/hlk_sw16/test.hlk_sw16.node.ts @@ -0,0 +1,72 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HlkSw16Client, HlkSw16ConfigFlow, HlkSw16Integration, HlkSw16Mapper, HomeAssistantHlkSw16Integration, createHlkSw16DiscoveryDescriptor, hlkSw16Profile, type IHlkSw16Snapshot, type THlkSw16RawData } from '../../ts/integrations/hlk_sw16/index.js'; + +const rawData: THlkSw16RawData = { + relays: { + '0': true, + '1': false, + a: true, + f: false, + }, +}; + +tap.test('matches manual HLK-SW16 candidates and creates config flow output', async () => { + const descriptor = createHlkSw16DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hlk_sw16-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.16', port: 8080, name: 'HLK-SW16 Board', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hlk_sw16'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HlkSw16ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.16'); + expect(done.config?.port).toEqual(8080); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps HLK-SW16 raw snapshots to runtime devices and entities', async () => { + const client = new HlkSw16Client({ name: 'HLK-SW16 Board', host: '192.0.2.16', rawData, switches: { '0': 'Pump', a: { name: 'Light Circuit' } } }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HlkSw16Mapper.toSnapshotFromRaw({ name: 'HLK-SW16 Board', host: '192.0.2.16', switches: { '0': 'Pump' } }, rawData); + const devices = HlkSw16Mapper.toDevices(mappedSnapshot); + const entities = HlkSw16Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.entities.length).toEqual(16); + expect(devices[0].integrationDomain).toEqual('hlk_sw16'); + expect(devices[0].manufacturer).toEqual('Hi-Link'); + expect(entities.some((entityArg) => entityArg.id === 'switch.hlk_sw16_board_relay_0')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.hlk_sw16_board_relay_a')).toBeTrue(); +}); + +tap.test('exposes HLK-SW16 read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HlkSw16Integration(); + const alias = new HomeAssistantHlkSw16Integration(); + expect(alias instanceof HlkSw16Integration).toBeTrue(); + expect(alias.domain).toEqual('hlk_sw16'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hlkSw16Profile.metadata.configFlow).toEqual(true); + expect(hlkSw16Profile.metadata.requirements).toEqual(['hlk-sw16==0.0.9']); + expect(hlkSw16Profile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ name: 'HLK-SW16 Board', host: '192.0.2.16', rawData }, {}); + const status = await runtime.callService!({ domain: 'hlk_sw16', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hlk_sw16', service: 'refresh', target: {} }); + const snapshot = status.data as IHlkSw16Snapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('HLK-SW16 Board'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.hlk_sw16_board_relay_0' } }); + 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/holiday/test.holiday.node.ts b/test/holiday/test.holiday.node.ts new file mode 100644 index 0000000..98ab4e5 --- /dev/null +++ b/test/holiday/test.holiday.node.ts @@ -0,0 +1,70 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HolidayClient, HolidayConfigFlow, HolidayIntegration, HolidayMapper, HomeAssistantHolidayIntegration, createHolidayDiscoveryDescriptor, holidayProfile, type IHolidaySnapshot, type THolidayRawData } from '../../ts/integrations/holiday/index.js'; + +const rawData: THolidayRawData = { + country: 'US', + holidays: [ + { date: '2099-01-01', name: "New Year's Day" }, + { date: '2099-12-25', name: 'Christmas Day' }, + ], +}; + +tap.test('matches manual Holiday candidates and creates config flow output', async () => { + const descriptor = createHolidayDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'holiday-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'us-holidays', name: 'US Holidays', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('holiday'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HolidayConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('US Holidays'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Holiday raw snapshots to runtime devices and entities', async () => { + const client = new HolidayClient({ name: 'US Holidays', country: 'US', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HolidayMapper.toSnapshotFromRaw({ name: 'US Holidays', country: 'US' }, rawData); + const devices = HolidayMapper.toDevices(mappedSnapshot); + const entities = HolidayMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('holiday'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.us_holidays_next_holiday')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.us_holidays_holiday_today')).toBeTrue(); +}); + +tap.test('exposes Holiday read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HolidayIntegration(); + const alias = new HomeAssistantHolidayIntegration(); + expect(alias instanceof HolidayIntegration).toBeTrue(); + expect(alias.domain).toEqual('holiday'); + expect(integration.status).toEqual('read-only-runtime'); + expect(holidayProfile.metadata.configFlow).toEqual(true); + expect(holidayProfile.metadata.requirements).toEqual(['holidays==0.95', 'babel==2.15.0']); + expect(holidayProfile.metadata.qualityScale).toBeUndefined(); + + const runtime = await integration.setup({ name: 'US Holidays', country: 'US', rawData }, {}); + const status = await runtime.callService!({ domain: 'holiday', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'holiday', service: 'refresh', target: {} }); + const snapshot = status.data as IHolidaySnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('US Holidays'); + + const command = await runtime.callService!({ domain: 'holiday', 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/homee/test.homee.node.ts b/test/homee/test.homee.node.ts new file mode 100644 index 0000000..2d6f6df --- /dev/null +++ b/test/homee/test.homee.node.ts @@ -0,0 +1,95 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHomeeIntegration, HomeeClient, HomeeConfigFlow, HomeeIntegration, HomeeMapper, createHomeeDiscoveryDescriptor, homeeProfile, type IHomeeSnapshot, type THomeeRawData } from '../../ts/integrations/homee/index.js'; + +const rawData: THomeeRawData = { + settings: { + uid: 'homee-1234', + homee_name: 'Homee Cube', + mac_address: '00:11:22:33:44:55', + version: '2.40.0', + }, + connected: true, + nodes: [ + { + id: 1, + name: 'Living Room Thermostat', + profile: 'room_thermostat', + state: 'available', + attributes: [ + { id: 1, name: 'Temperature', type: 'temperature', value: 21.5, unit: 'C', platform: 'sensor' }, + { id: 2, name: 'Target Temperature', type: 'target_temperature', value: 20, unit: 'C', platform: 'climate', writable: true }, + ], + }, + { + id: 2, + name: 'Kitchen Plug', + profile: 'metering_switch', + state: 'available', + attributes: [ + { id: 1, name: 'Switch', type: 'on_off', value: true, platform: 'switch', writable: true }, + { id: 2, name: 'Energy', type: 'energy', value: 1.2, unit: 'kWh' }, + ], + }, + ], +}; + +tap.test('matches manual Homee candidates and creates config flow output', async () => { + const descriptor = createHomeeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homee-manual-match'); + const result = await matcher!.matches({ source: 'manual', host: '192.0.2.50', id: 'homee-1234', name: 'Homee Cube', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('homee'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HomeeConfigFlow().start(result.candidate!, {})).submit!({ username: 'user', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.50'); + expect(done.config?.username).toEqual('user'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Homee raw snapshots to runtime devices and entities', async () => { + const client = new HomeeClient({ name: 'Homee Cube', host: '192.0.2.50', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HomeeMapper.toSnapshotFromRaw({ name: 'Homee Cube', host: '192.0.2.50' }, rawData); + const devices = HomeeMapper.toDevices(mappedSnapshot); + const entities = HomeeMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('homee'); + expect(devices[0].manufacturer).toEqual('homee'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.homee_cube_living_room_thermostat_temperature')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.homee_cube_kitchen_plug_switch')).toBeTrue(); +}); + +tap.test('exposes Homee read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HomeeIntegration(); + const alias = new HomeAssistantHomeeIntegration(); + expect(alias instanceof HomeeIntegration).toBeTrue(); + expect(alias.domain).toEqual('homee'); + expect(integration.status).toEqual('read-only-runtime'); + expect(homeeProfile.metadata.configFlow).toEqual(true); + expect(homeeProfile.metadata.qualityScale).toEqual('silver'); + expect(homeeProfile.metadata.requirements).toEqual(['pyHomee==1.3.8']); + + const runtime = await integration.setup({ name: 'Homee Cube', host: '192.0.2.50', rawData }, {}); + const status = await runtime.callService!({ domain: 'homee', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'homee', service: 'refresh', target: {} }); + const snapshot = status.data as IHomeeSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Homee Cube'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.homee_cube_kitchen_plug_switch' } }); + 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/homekit/test.homekit.node.ts b/test/homekit/test.homekit.node.ts new file mode 100644 index 0000000..dfa9dd9 --- /dev/null +++ b/test/homekit/test.homekit.node.ts @@ -0,0 +1,78 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHomekitIntegration, HomekitClient, HomekitConfigFlow, HomekitIntegration, HomekitMapper, createHomekitDiscoveryDescriptor, homekitProfile, type IHomekitSnapshot, type THomekitRawData } from '../../ts/integrations/homekit/index.js'; + +const rawData: THomekitRawData = { + device: { + id: 'homekit-bridge', + name: 'HomeKit Bridge', + manufacturer: 'Home Assistant', + model: 'HomeKit Bridge', + }, + entities: [ + { id: 'living_room_lamp', name: 'Living Room Lamp', platform: 'light', state: true, writable: true, attributes: { brightness: 180, homekitService: 'Lightbulb' } }, + { id: 'front_door', name: 'Front Door', platform: 'binary_sensor', state: false, deviceClass: 'door', attributes: { homekitService: 'ContactSensor' } }, + { id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'heat', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 21 } }, + ], + mode: 'bridge', + port: 21064, +}; + +tap.test('matches manual HomeKit candidates and creates config flow output', async () => { + const descriptor = createHomekitDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homekit-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'homekit-bridge', name: 'HomeKit Bridge', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('homekit'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HomekitConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('HomeKit Bridge'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps HomeKit raw snapshots to runtime devices and entities', async () => { + const client = new HomekitClient({ name: 'HomeKit Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HomekitMapper.toSnapshotFromRaw({ name: 'HomeKit Runtime' }, rawData); + const devices = HomekitMapper.toDevices(mappedSnapshot); + const entities = HomekitMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('homekit'); + expect(devices[0].manufacturer).toEqual('Home Assistant'); + expect(entities.some((entityArg) => entityArg.id === 'light.homekit_bridge_living_room_lamp')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.homekit_bridge_front_door')).toBeTrue(); +}); + +tap.test('exposes HomeKit read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HomekitIntegration(); + const alias = new HomeAssistantHomekitIntegration(); + expect(alias instanceof HomekitIntegration).toBeTrue(); + expect(alias.domain).toEqual('homekit'); + expect(integration.status).toEqual('read-only-runtime'); + expect(homekitProfile.metadata.configFlow).toEqual(true); + expect(homekitProfile.metadata.requirements).toEqual(['HAP-python==5.0.0', 'fnv-hash-fast==2.0.2', 'homekit-audio-proxy==1.2.1', 'PyQRCode==1.2.1', 'base36==0.1.1']); + expect(homekitProfile.metadata.afterDependencies).toEqual(['camera', 'zeroconf']); + + const runtime = await integration.setup({ name: 'HomeKit Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'homekit', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'homekit', service: 'refresh', target: {} }); + const snapshot = status.data as IHomekitSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('HomeKit Bridge'); + + const command = await runtime.callService!({ domain: 'homekit', service: 'reset_accessory', target: {}, data: { entity_id: ['light.homekit_bridge_living_room_lamp'] } }); + 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/homevolt/test.homevolt.node.ts b/test/homevolt/test.homevolt.node.ts new file mode 100644 index 0000000..f9bf7f6 --- /dev/null +++ b/test/homevolt/test.homevolt.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHomevoltIntegration, HomevoltClient, HomevoltConfigFlow, HomevoltIntegration, HomevoltMapper, createHomevoltDiscoveryDescriptor, homevoltProfile, type IHomevoltSnapshot, type THomevoltRawData } from '../../ts/integrations/homevolt/index.js'; + +const rawData: THomevoltRawData = { + device: { + id: 'homevolt-ems-1', + name: 'Homevolt Battery', + manufacturer: 'Homevolt', + model: 'Battery system', + host: 'homevolt.local', + }, + entities: [ + { id: 'state_of_charge', name: 'State Of Charge', platform: 'sensor', state: 67, unit: '%', deviceClass: 'battery' }, + { id: 'available_charging_power', name: 'Available Charging Power', platform: 'sensor', state: 4200, unit: 'W', deviceClass: 'power' }, + { id: 'energy_imported', name: 'Energy Imported', platform: 'sensor', state: 18.4, unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }, + { id: 'local_mode', name: 'Local Mode', platform: 'switch', state: true, writable: true, attributes: { entityCategory: 'config' } }, + ], + uniqueId: 'HV-1234', +}; + +tap.test('matches manual Homevolt candidates and creates config flow output', async () => { + const descriptor = createHomevoltDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homevolt-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'HV-1234', name: 'Homevolt Battery', host: 'homevolt.local', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('homevolt'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HomevoltConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('homevolt.local'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Homevolt raw snapshots to runtime devices and entities', async () => { + const client = new HomevoltClient({ name: 'Homevolt Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HomevoltMapper.toSnapshotFromRaw({ name: 'Homevolt Runtime' }, rawData); + const devices = HomevoltMapper.toDevices(mappedSnapshot); + const entities = HomevoltMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('homevolt'); + expect(devices[0].manufacturer).toEqual('Homevolt'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.homevolt_battery_state_of_charge')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.homevolt_battery_local_mode')).toBeTrue(); +}); + +tap.test('exposes Homevolt read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HomevoltIntegration(); + const alias = new HomeAssistantHomevoltIntegration(); + expect(alias instanceof HomevoltIntegration).toBeTrue(); + expect(alias.domain).toEqual('homevolt'); + expect(integration.status).toEqual('read-only-runtime'); + expect(homevoltProfile.metadata.configFlow).toEqual(true); + expect(homevoltProfile.metadata.qualityScale).toEqual('silver'); + expect(homevoltProfile.metadata.requirements).toEqual(['homevolt==0.5.0']); + + const runtime = await integration.setup({ name: 'Homevolt Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'homevolt', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'homevolt', service: 'refresh', target: {} }); + const snapshot = status.data as IHomevoltSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Homevolt Battery'); + + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.homevolt_battery_local_mode' } }); + 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/homeworks/test.homeworks.node.ts b/test/homeworks/test.homeworks.node.ts new file mode 100644 index 0000000..ba2165a --- /dev/null +++ b/test/homeworks/test.homeworks.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHomeworksIntegration, HomeworksClient, HomeworksConfigFlow, HomeworksIntegration, HomeworksMapper, createHomeworksDiscoveryDescriptor, homeworksProfile, type IHomeworksSnapshot, type THomeworksRawData } from '../../ts/integrations/homeworks/index.js'; + +const rawData: THomeworksRawData = { + device: { + id: 'homeworks-main', + name: 'Lutron Homeworks', + manufacturer: 'Lutron', + model: 'Homeworks controller', + host: 'homeworks.local', + }, + entities: [ + { id: 'kitchen_dimmer', name: 'Kitchen Dimmer', platform: 'light', state: 128, writable: true, attributes: { address: '[02:08:02:01]', rate: 1 } }, + { id: 'keypad_button_1', name: 'Keypad Button 1', platform: 'button', state: 'idle', writable: true, attributes: { address: '[02:08:02:02]', number: 1 } }, + { id: 'keypad_led_1', name: 'Keypad LED 1', platform: 'binary_sensor', state: true, attributes: { address: '[02:08:02:02]', number: 1 } }, + ], + controllerId: 'lutron_homeworks', +}; + +tap.test('matches manual Homeworks candidates and creates config flow output', async () => { + const descriptor = createHomeworksDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homeworks-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lutron_homeworks', name: 'Lutron Homeworks', host: 'homeworks.local', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('homeworks'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HomeworksConfigFlow().start(result.candidate!, {})).submit!({ port: 23 }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('homeworks.local'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Homeworks raw snapshots to runtime devices and entities', async () => { + const client = new HomeworksClient({ name: 'Homeworks Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HomeworksMapper.toSnapshotFromRaw({ name: 'Homeworks Runtime' }, rawData); + const devices = HomeworksMapper.toDevices(mappedSnapshot); + const entities = HomeworksMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('homeworks'); + expect(devices[0].manufacturer).toEqual('Lutron'); + expect(entities.some((entityArg) => entityArg.id === 'light.lutron_homeworks_kitchen_dimmer')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'button.lutron_homeworks_keypad_button_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.lutron_homeworks_keypad_led_1')).toBeTrue(); +}); + +tap.test('exposes Homeworks read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HomeworksIntegration(); + const alias = new HomeAssistantHomeworksIntegration(); + expect(alias instanceof HomeworksIntegration).toBeTrue(); + expect(alias.domain).toEqual('homeworks'); + expect(integration.status).toEqual('read-only-runtime'); + expect(homeworksProfile.metadata.configFlow).toEqual(true); + expect(homeworksProfile.metadata.requirements).toEqual(['pyhomeworks==1.1.2']); + expect(homeworksProfile.metadata.dependencies).toEqual([]); + + const runtime = await integration.setup({ name: 'Homeworks Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'homeworks', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'homeworks', service: 'refresh', target: {} }); + const snapshot = status.data as IHomeworksSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Lutron Homeworks'); + + const command = await runtime.callService!({ domain: 'homeworks', service: 'send_command', target: {}, data: { controller_id: 'lutron_homeworks', command: ['KBP, [02:08:02:02], 1'] } }); + 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/horizon/test.horizon.node.ts b/test/horizon/test.horizon.node.ts new file mode 100644 index 0000000..0860485 --- /dev/null +++ b/test/horizon/test.horizon.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHorizonIntegration, HorizonClient, HorizonConfigFlow, HorizonIntegration, HorizonMapper, createHorizonDiscoveryDescriptor, horizonProfile, type IHorizonSnapshot, type THorizonRawData } from '../../ts/integrations/horizon/index.js'; + +const rawData: THorizonRawData = { + device: { + id: 'horizon-recorder', + name: 'Horizon Recorder', + manufacturer: 'Unitymedia', + model: 'Horizon HD Recorder', + host: 'horizon.local', + port: 5900, + }, + entities: [ + { id: 'media_player', name: 'Media Player', platform: 'media_player', state: 'playing', writable: true, attributes: { mediaType: 'channel', channel: 101, supportedFeatures: ['turn_on', 'turn_off', 'media_play', 'media_pause'] } }, + ], +}; + +tap.test('matches manual Horizon candidates and creates config flow output', async () => { + const descriptor = createHorizonDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'horizon-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'horizon-recorder', name: 'Horizon Recorder', host: 'horizon.local', port: 5900, metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('horizon'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HorizonConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('horizon.local'); + expect(done.config?.port).toEqual(5900); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Horizon raw snapshots to runtime devices and entities', async () => { + const client = new HorizonClient({ name: 'Horizon Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HorizonMapper.toSnapshotFromRaw({ name: 'Horizon Runtime' }, rawData); + const devices = HorizonMapper.toDevices(mappedSnapshot); + const entities = HorizonMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('horizon'); + expect(devices[0].manufacturer).toEqual('Unitymedia'); + expect(entities.some((entityArg) => entityArg.id === 'media_player.horizon_recorder_media_player')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'media_player' && entityArg.state === 'playing')).toBeTrue(); +}); + +tap.test('exposes Horizon read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HorizonIntegration(); + const alias = new HomeAssistantHorizonIntegration(); + expect(alias instanceof HorizonIntegration).toBeTrue(); + expect(alias.domain).toEqual('horizon'); + expect(integration.status).toEqual('read-only-runtime'); + expect(horizonProfile.metadata.configFlow).toEqual(false); + expect(horizonProfile.metadata.qualityScale).toEqual('legacy'); + expect(horizonProfile.metadata.requirements).toEqual(['horimote==0.4.1']); + + const runtime = await integration.setup({ name: 'Horizon Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'horizon', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'horizon', service: 'refresh', target: {} }); + const snapshot = status.data as IHorizonSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Horizon Recorder'); + + const command = await runtime.callService!({ domain: 'media_player', service: 'media_play_pause', target: { entityId: 'media_player.horizon_recorder_media_player' } }); + 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/hp_ilo/test.hp_ilo.node.ts b/test/hp_ilo/test.hp_ilo.node.ts new file mode 100644 index 0000000..1956892 --- /dev/null +++ b/test/hp_ilo/test.hp_ilo.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHpIloIntegration, HpIloClient, HpIloConfigFlow, HpIloIntegration, HpIloMapper, createHpIloDiscoveryDescriptor, hpIloProfile, type IHpIloSnapshot, type THpIloRawData } from '../../ts/integrations/hp_ilo/index.js'; + +const rawData: THpIloRawData = { + device: { + id: 'hp-ilo-server-1', + name: 'HP iLO Server', + manufacturer: 'HP', + model: 'Integrated Lights-Out', + host: 'ilo.local', + port: 443, + }, + entities: [ + { id: 'server_power_status', name: 'Server Power Status', platform: 'sensor', state: 'ON' }, + { id: 'server_health', name: 'Server Health', platform: 'sensor', state: 'OK' }, + { id: 'server_uid_status', name: 'Server UID Status', platform: 'sensor', state: 'OFF' }, + { id: 'server_power_on_time', name: 'Server Power On Time', platform: 'sensor', state: 123456, unit: 's' }, + ], +}; + +tap.test('matches manual HP iLO candidates and creates config flow output', async () => { + const descriptor = createHpIloDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hp_ilo-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'hp-ilo-server-1', name: 'HP iLO Server', host: 'ilo.local', port: 443, metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hp_ilo'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HpIloConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('ilo.local'); + expect(done.config?.port).toEqual(443); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps HP iLO raw snapshots to runtime devices and entities', async () => { + const client = new HpIloClient({ name: 'HP iLO Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HpIloMapper.toSnapshotFromRaw({ name: 'HP iLO Runtime' }, rawData); + const devices = HpIloMapper.toDevices(mappedSnapshot); + const entities = HpIloMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hp_ilo'); + expect(devices[0].manufacturer).toEqual('HP'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.hp_ilo_server_server_power_status')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.hp_ilo_server_server_health')).toBeTrue(); +}); + +tap.test('exposes HP iLO read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HpIloIntegration(); + const alias = new HomeAssistantHpIloIntegration(); + expect(alias instanceof HpIloIntegration).toBeTrue(); + expect(alias.domain).toEqual('hp_ilo'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hpIloProfile.metadata.configFlow).toEqual(false); + expect(hpIloProfile.metadata.qualityScale).toEqual('legacy'); + expect(hpIloProfile.metadata.requirements).toEqual(['python-hpilo==4.4.3']); + + const runtime = await integration.setup({ name: 'HP iLO Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'hp_ilo', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hp_ilo', service: 'refresh', target: {} }); + const snapshot = status.data as IHpIloSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('HP iLO Server'); + + const command = await runtime.callService!({ domain: 'hp_ilo', service: 'turn_on', target: { deviceId: 'hp_ilo.device.hp_ilo_server' } }); + 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/hr_energy_qube/test.hr_energy_qube.node.ts b/test/hr_energy_qube/test.hr_energy_qube.node.ts new file mode 100644 index 0000000..bfc10d2 --- /dev/null +++ b/test/hr_energy_qube/test.hr_energy_qube.node.ts @@ -0,0 +1,83 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHrEnergyQubeIntegration, HrEnergyQubeClient, HrEnergyQubeConfigFlow, HrEnergyQubeIntegration, HrEnergyQubeMapper, createHrEnergyQubeDiscoveryDescriptor, hrEnergyQubeProfile, type IHrEnergyQubeSnapshot, type THrEnergyQubeRawData } from '../../ts/integrations/hr_energy_qube/index.js'; + +const rawData: THrEnergyQubeRawData = { + device: { + id: 'qube-heat-pump', + name: 'Qube Heat Pump', + manufacturer: 'Qube', + model: 'Heat Pump', + host: '192.0.2.10', + port: 502, + }, + entities: [ + { id: 'temp_supply', name: 'Supply temperature CH', platform: 'sensor', state: 35.2, unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { id: 'power_electric', name: 'Electric power', platform: 'sensor', state: 1200, unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + { id: 'status_heatpump', name: 'Heat pump status', platform: 'sensor', state: 'heating', deviceClass: 'enum' }, + { id: 'alarm_global', name: 'Global alarm', platform: 'binary_sensor', state: false, deviceClass: 'problem' }, + ], + registers: { + statusCode: 16, + softwareVersion: '1.8.0', + }, +}; + +tap.test('matches manual Qube candidates and creates config flow output', async () => { + const descriptor = createHrEnergyQubeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hr_energy_qube-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'qube-heat-pump', name: 'Qube heat pump', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hr_energy_qube'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HrEnergyQubeConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Qube heat pump'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Qube raw snapshots to runtime devices and entities', async () => { + const client = new HrEnergyQubeClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HrEnergyQubeMapper.toSnapshotFromRaw({}, rawData); + const devices = HrEnergyQubeMapper.toDevices(mappedSnapshot); + const entities = HrEnergyQubeMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hr_energy_qube'); + expect(devices[0].manufacturer).toEqual('Qube'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.qube_heat_pump_temp_supply')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.qube_heat_pump_alarm_global')).toBeTrue(); +}); + +tap.test('exposes Qube read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HrEnergyQubeIntegration(); + const alias = new HomeAssistantHrEnergyQubeIntegration(); + expect(alias instanceof HrEnergyQubeIntegration).toBeTrue(); + expect(alias.domain).toEqual('hr_energy_qube'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hrEnergyQubeProfile.metadata.configFlow).toEqual(true); + expect(hrEnergyQubeProfile.metadata.qualityScale).toEqual('bronze'); + expect(hrEnergyQubeProfile.metadata.requirements).toEqual(['python-qube-heatpump==1.8.0']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'hr_energy_qube', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hr_energy_qube', service: 'refresh', target: {} }); + const snapshot = status.data as IHrEnergyQubeSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Qube Heat Pump'); + + const command = await runtime.callService!({ domain: 'hr_energy_qube', 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/hue_ble/test.hue_ble.node.ts b/test/hue_ble/test.hue_ble.node.ts new file mode 100644 index 0000000..b69e6dd --- /dev/null +++ b/test/hue_ble/test.hue_ble.node.ts @@ -0,0 +1,89 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHueBleIntegration, HueBleClient, HueBleConfigFlow, HueBleIntegration, HueBleMapper, createHueBleDiscoveryDescriptor, hueBleProfile, type IHueBleSnapshot, type THueBleRawData } from '../../ts/integrations/hue_ble/index.js'; + +const rawData: THueBleRawData = { + device: { + id: 'aa:bb:cc:dd:ee:ff', + name: 'Hue BLE Lamp', + manufacturer: 'Philips Hue', + model: 'Hue BLE light', + serialNumber: 'AA:BB:CC:DD:EE:FF', + protocol: 'local', + }, + entities: [ + { + id: 'light', + name: 'Light', + platform: 'light', + state: true, + writable: true, + attributes: { + brightness: 190, + colorMode: 'color_temp', + colorTempKelvin: 2700, + supportedColorModes: ['brightness', 'color_temp', 'xy'], + xyColor: [0.45, 0.41], + }, + }, + ], +}; + +tap.test('matches manual Hue BLE candidates and creates config flow output', async () => { + const descriptor = createHueBleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hue_ble-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'aa:bb:cc:dd:ee:ff', name: 'Hue BLE Lamp', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('hue_ble'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HueBleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Hue BLE Lamp'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Hue BLE raw snapshots to runtime devices and entities', async () => { + const client = new HueBleClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HueBleMapper.toSnapshotFromRaw({}, rawData); + const devices = HueBleMapper.toDevices(mappedSnapshot); + const entities = HueBleMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('hue_ble'); + expect(devices[0].manufacturer).toEqual('Philips Hue'); + expect(entities.some((entityArg) => entityArg.id === 'light.hue_ble_lamp_light')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'light.hue_ble_lamp_light')?.attributes?.brightness).toEqual(190); +}); + +tap.test('exposes Hue BLE read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HueBleIntegration(); + const alias = new HomeAssistantHueBleIntegration(); + expect(alias instanceof HueBleIntegration).toBeTrue(); + expect(alias.domain).toEqual('hue_ble'); + expect(integration.status).toEqual('read-only-runtime'); + expect(hueBleProfile.metadata.configFlow).toEqual(true); + expect(hueBleProfile.metadata.qualityScale).toEqual('bronze'); + expect(hueBleProfile.metadata.dependencies).toEqual(['bluetooth_adapters']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'light', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'hue_ble', service: 'refresh', target: {} }); + const snapshot = status.data as IHueBleSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Hue BLE Lamp'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.hue_ble_lamp_light' }, data: { brightness: 255 } }); + 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/husqvarna_automower_ble/test.husqvarna_automower_ble.node.ts b/test/husqvarna_automower_ble/test.husqvarna_automower_ble.node.ts new file mode 100644 index 0000000..34739be --- /dev/null +++ b/test/husqvarna_automower_ble/test.husqvarna_automower_ble.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantHusqvarnaAutomowerBleIntegration, HusqvarnaAutomowerBleClient, HusqvarnaAutomowerBleConfigFlow, HusqvarnaAutomowerBleIntegration, HusqvarnaAutomowerBleMapper, createHusqvarnaAutomowerBleDiscoveryDescriptor, husqvarnaAutomowerBleProfile, type IHusqvarnaAutomowerBleSnapshot, type THusqvarnaAutomowerBleRawData } from '../../ts/integrations/husqvarna_automower_ble/index.js'; + +const rawData: THusqvarnaAutomowerBleRawData = { + device: { + id: 'husqvarna-aa-bb-cc-dd-ee-ff', + name: 'Garden Automower', + manufacturer: 'Husqvarna', + model: 'Automower 430X', + serialNumber: 'AA:BB:CC:DD:EE:FF', + protocol: 'local', + }, + entities: [ + { id: 'activity', name: 'Mower activity', platform: 'sensor', state: 'mowing' }, + { id: 'state', name: 'Mower state', platform: 'sensor', state: 'in_operation' }, + { id: 'battery_level', name: 'Battery level', platform: 'sensor', state: 82, unit: '%', deviceClass: 'battery', stateClass: 'measurement' }, + ], + mower: { + activity: 'mowing', + state: 'in_operation', + }, +}; + +tap.test('matches manual Husqvarna Automower BLE candidates and creates config flow output', async () => { + const descriptor = createHusqvarnaAutomowerBleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'husqvarna_automower_ble-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'husqvarna-aa-bb-cc-dd-ee-ff', name: 'Garden Automower', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('husqvarna_automower_ble'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new HusqvarnaAutomowerBleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Garden Automower'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Husqvarna Automower BLE raw snapshots to runtime devices and entities', async () => { + const client = new HusqvarnaAutomowerBleClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = HusqvarnaAutomowerBleMapper.toSnapshotFromRaw({}, rawData); + const devices = HusqvarnaAutomowerBleMapper.toDevices(mappedSnapshot); + const entities = HusqvarnaAutomowerBleMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('husqvarna_automower_ble'); + expect(devices[0].manufacturer).toEqual('Husqvarna'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.garden_automower_activity')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.garden_automower_battery_level')?.state).toEqual(82); +}); + +tap.test('exposes Husqvarna Automower BLE read-only runtime, HA alias, and unsupported control', async () => { + const integration = new HusqvarnaAutomowerBleIntegration(); + const alias = new HomeAssistantHusqvarnaAutomowerBleIntegration(); + expect(alias instanceof HusqvarnaAutomowerBleIntegration).toBeTrue(); + expect(alias.domain).toEqual('husqvarna_automower_ble'); + expect(integration.status).toEqual('read-only-runtime'); + expect(husqvarnaAutomowerBleProfile.metadata.configFlow).toEqual(true); + expect(husqvarnaAutomowerBleProfile.metadata.qualityScale).toEqual(undefined); + expect(husqvarnaAutomowerBleProfile.metadata.dependencies).toEqual(['bluetooth_adapters']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'lawn_mower', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'husqvarna_automower_ble', service: 'refresh', target: {} }); + const snapshot = status.data as IHusqvarnaAutomowerBleSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Garden Automower'); + + const command = await runtime.callService!({ domain: 'lawn_mower', service: 'start_mowing', target: { entityId: 'lawn_mower.garden_automower' } }); + 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/ialarm/test.ialarm.node.ts b/test/ialarm/test.ialarm.node.ts new file mode 100644 index 0000000..3f3b054 --- /dev/null +++ b/test/ialarm/test.ialarm.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIalarmIntegration, IalarmClient, IalarmConfigFlow, IalarmIntegration, IalarmMapper, createIalarmDiscoveryDescriptor, ialarmProfile, type IIalarmSnapshot, type TIalarmRawData } from '../../ts/integrations/ialarm/index.js'; + +const rawData: TIalarmRawData = { + device: { + id: 'ialarm-aa-bb-cc-dd-ee-ff', + name: 'iAlarm', + manufacturer: 'Antifurto365 - Meian', + model: 'iAlarm', + serialNumber: 'AA:BB:CC:DD:EE:FF', + host: '192.0.2.34', + port: 18034, + }, + entities: [ + { id: 'alarm_state', name: 'Alarm state', platform: 'sensor', state: 'armed_away', writable: true, attributes: { supportedFeatures: ['arm_home', 'arm_away'] } }, + { id: 'online', name: 'Online', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' }, + ], + status: 'armed_away', +}; + +tap.test('matches manual iAlarm candidates and creates config flow output', async () => { + const descriptor = createIalarmDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ialarm-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'ialarm-aa-bb-cc-dd-ee-ff', name: 'Antifurto365 iAlarm', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('ialarm'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IalarmConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('Antifurto365 iAlarm'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps iAlarm raw snapshots to runtime devices and entities', async () => { + const client = new IalarmClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IalarmMapper.toSnapshotFromRaw({}, rawData); + const devices = IalarmMapper.toDevices(mappedSnapshot); + const entities = IalarmMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('ialarm'); + expect(devices[0].manufacturer).toEqual('Antifurto365 - Meian'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.ialarm_alarm_state')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.ialarm_alarm_state')?.state).toEqual('armed_away'); +}); + +tap.test('exposes iAlarm read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IalarmIntegration(); + const alias = new HomeAssistantIalarmIntegration(); + expect(alias instanceof IalarmIntegration).toBeTrue(); + expect(alias.domain).toEqual('ialarm'); + expect(integration.status).toEqual('read-only-runtime'); + expect(ialarmProfile.metadata.configFlow).toEqual(true); + expect(ialarmProfile.metadata.qualityScale).toEqual(undefined); + expect(ialarmProfile.metadata.requirements).toEqual(['pyialarm==2.2.0']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'alarm_control_panel', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'ialarm', service: 'refresh', target: {} }); + const snapshot = status.data as IIalarmSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('iAlarm'); + + const command = await runtime.callService!({ domain: 'alarm_control_panel', service: 'alarm_arm_away', target: { entityId: 'alarm_control_panel.ialarm' } }); + 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/iammeter/test.iammeter.node.ts b/test/iammeter/test.iammeter.node.ts new file mode 100644 index 0000000..ad53fe0 --- /dev/null +++ b/test/iammeter/test.iammeter.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIammeterIntegration, IammeterClient, IammeterConfigFlow, IammeterIntegration, IammeterMapper, createIammeterDiscoveryDescriptor, iammeterProfile, type IIammeterSnapshot, type TIammeterRawData } from '../../ts/integrations/iammeter/index.js'; + +const rawData: TIammeterRawData = { + device: { + id: 'iammeter-12345678', + name: 'IamMeter WEM3080T', + manufacturer: 'IamMeter', + model: 'WEM3080T', + serialNumber: '12345678', + host: '192.0.2.15', + port: 80, + }, + entities: [ + { id: 'Voltage_A', name: 'Voltage A', platform: 'sensor', state: 231.2, unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }, + { id: 'Current_A', name: 'Current A', platform: 'sensor', state: 4.1, unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + { id: 'Power_A', name: 'Power A', platform: 'sensor', state: 840, unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + { id: 'ImportEnergy_A', name: 'ImportEnergy A', platform: 'sensor', state: 1234.56, unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }, + ], + Model: 'WEM3080T', + sn: '12345678', +}; + +tap.test('matches manual IamMeter candidates and creates config flow output', async () => { + const descriptor = createIammeterDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iammeter-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iammeter-12345678', name: 'IamMeter WEM3080T', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('iammeter'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IammeterConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.name).toEqual('IamMeter WEM3080T'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps IamMeter raw snapshots to runtime devices and entities', async () => { + const client = new IammeterClient({ rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IammeterMapper.toSnapshotFromRaw({}, rawData); + const devices = IammeterMapper.toDevices(mappedSnapshot); + const entities = IammeterMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('iammeter'); + expect(devices[0].manufacturer).toEqual('IamMeter'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.iammeter_wem3080t_voltage_a')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.iammeter_wem3080t_power_a')?.state).toEqual(840); +}); + +tap.test('exposes IamMeter read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IammeterIntegration(); + const alias = new HomeAssistantIammeterIntegration(); + expect(alias instanceof IammeterIntegration).toBeTrue(); + expect(alias.domain).toEqual('iammeter'); + expect(integration.status).toEqual('read-only-runtime'); + expect(iammeterProfile.metadata.configFlow).toEqual(false); + expect(iammeterProfile.metadata.qualityScale).toEqual('legacy'); + expect(iammeterProfile.metadata.requirements).toEqual(['iammeter==0.2.1']); + + const runtime = await integration.setup({ rawData }, {}); + const status = await runtime.callService!({ domain: 'iammeter', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'iammeter', service: 'refresh', target: {} }); + const snapshot = status.data as IIammeterSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('IamMeter WEM3080T'); + + const command = await runtime.callService!({ domain: 'iammeter', 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/ibeacon/test.ibeacon.node.ts b/test/ibeacon/test.ibeacon.node.ts new file mode 100644 index 0000000..ae7acea --- /dev/null +++ b/test/ibeacon/test.ibeacon.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IbeaconClient, IbeaconConfigFlow, IbeaconIntegration, IbeaconMapper, HomeAssistantIbeaconIntegration, createIbeaconDiscoveryDescriptor, ibeaconProfile, type IIbeaconSnapshot, type TIbeaconRawData } from '../../ts/integrations/ibeacon/index.js'; + +const rawData: TIbeaconRawData = { + device: { + id: 'fda50693-a4e2-4fb1-afcf-c6eb07647825_10_42_AA:BB:CC:DD:EE:FF', + name: 'Desk Beacon', + manufacturer: 'Apple', + model: 'iBeacon advertisement', + serialNumber: 'fda50693-a4e2-4fb1-afcf-c6eb07647825_10_42', + attributes: { + address: 'AA:BB:CC:DD:EE:FF', + source: 'bluetooth', + uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825', + major: 10, + minor: 42, + }, + }, + entities: [ + { id: 'presence', name: 'Presence', platform: 'binary_sensor', state: true, deviceClass: 'presence' }, + { id: 'rssi', name: 'RSSI', platform: 'sensor', state: -63, unit: 'dBm', deviceClass: 'signal_strength', stateClass: 'measurement' }, + { id: 'power', name: 'Power', platform: 'sensor', state: -59, unit: 'dBm', deviceClass: 'signal_strength', stateClass: 'measurement' }, + { id: 'estimated_distance', name: 'Estimated Distance', platform: 'sensor', state: 1.8, unit: 'm', deviceClass: 'distance', stateClass: 'measurement' }, + ], +}; + +tap.test('matches manual iBeacon candidates and creates config flow output', async () => { + const descriptor = createIbeaconDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ibeacon-manual-match'); + const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Desk iBeacon', metadata: { uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825', rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('ibeacon'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IbeaconConfigFlow().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 iBeacon raw snapshots to runtime devices and entities', async () => { + const client = new IbeaconClient({ name: 'iBeacon Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IbeaconMapper.toSnapshotFromRaw({ name: 'iBeacon Runtime' }, rawData); + const devices = IbeaconMapper.toDevices(mappedSnapshot); + const entities = IbeaconMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('ibeacon'); + expect(devices[0].manufacturer).toEqual('Apple'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.desk_beacon_presence')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.desk_beacon_estimated_distance')).toBeTrue(); +}); + +tap.test('exposes iBeacon read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IbeaconIntegration(); + const alias = new HomeAssistantIbeaconIntegration(); + expect(alias instanceof IbeaconIntegration).toBeTrue(); + expect(alias.domain).toEqual('ibeacon'); + expect(integration.status).toEqual('read-only-runtime'); + expect(ibeaconProfile.metadata.configFlow).toEqual(true); + expect(ibeaconProfile.metadata.requirements).toEqual(['ibeacon-ble==1.2.0']); + expect(ibeaconProfile.metadata.dependencies).toEqual(['bluetooth_adapters']); + + const runtime = await integration.setup({ name: 'iBeacon Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'ibeacon', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'ibeacon', service: 'refresh', target: {} }); + const snapshot = status.data as IIbeaconSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Desk Beacon'); + + const command = await runtime.callService!({ domain: 'ibeacon', 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/idasen_desk/test.idasen_desk.node.ts b/test/idasen_desk/test.idasen_desk.node.ts new file mode 100644 index 0000000..faab043 --- /dev/null +++ b/test/idasen_desk/test.idasen_desk.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IdasenDeskClient, IdasenDeskConfigFlow, IdasenDeskIntegration, IdasenDeskMapper, HomeAssistantIdasenDeskIntegration, createIdasenDeskDiscoveryDescriptor, idasenDeskProfile, idasenDeskServiceUuid, type IIdasenDeskSnapshot, type TIdasenDeskRawData } from '../../ts/integrations/idasen_desk/index.js'; + +const rawData: TIdasenDeskRawData = { + device: { + id: 'idasen-AA:BB:CC:DD:EE:FF', + name: 'Bedroom Desk', + manufacturer: 'LINAK', + model: 'IKEA Idasen Desk', + serialNumber: 'AA:BB:CC:DD:EE:FF', + attributes: { + address: 'AA:BB:CC:DD:EE:FF', + serviceUuid: idasenDeskServiceUuid, + }, + }, + entities: [ + { id: 'desk', name: 'Desk', platform: 'cover', state: 'open', writable: true, attributes: { currentPosition: 72, supportedFeatures: ['open', 'close', 'stop', 'set_position'] } }, + { id: 'height', name: 'Height', platform: 'sensor', state: 1.13, unit: 'm', deviceClass: 'distance', stateClass: 'measurement' }, + { id: 'connect', name: 'Connect', platform: 'button', state: 'idle', writable: true, attributes: { category: 'config' } }, + { id: 'disconnect', name: 'Disconnect', platform: 'button', state: 'idle', writable: true, attributes: { category: 'config' } }, + ], +}; + +tap.test('matches manual Idasen Desk candidates and creates config flow output', async () => { + const descriptor = createIdasenDeskDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'idasen_desk-manual-match'); + const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Bedroom Idasen Desk', metadata: { address: 'AA:BB:CC:DD:EE:FF', serviceUuid: idasenDeskServiceUuid, rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('idasen_desk'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IdasenDeskConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF'); + expect(done.config?.metadata?.serviceUuid).toEqual(idasenDeskServiceUuid); +}); + +tap.test('maps Idasen Desk raw snapshots to runtime devices and entities', async () => { + const client = new IdasenDeskClient({ name: 'Desk Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IdasenDeskMapper.toSnapshotFromRaw({ name: 'Desk Runtime' }, rawData); + const devices = IdasenDeskMapper.toDevices(mappedSnapshot); + const entities = IdasenDeskMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('idasen_desk'); + expect(devices[0].manufacturer).toEqual('LINAK'); + expect(entities.some((entityArg) => entityArg.id === 'cover.bedroom_desk_desk')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_desk_height')).toBeTrue(); +}); + +tap.test('exposes Idasen Desk read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IdasenDeskIntegration(); + const alias = new HomeAssistantIdasenDeskIntegration(); + expect(alias instanceof IdasenDeskIntegration).toBeTrue(); + expect(alias.domain).toEqual('idasen_desk'); + expect(integration.status).toEqual('read-only-runtime'); + expect(idasenDeskProfile.metadata.configFlow).toEqual(true); + expect(idasenDeskProfile.metadata.qualityScale).toEqual('bronze'); + expect(idasenDeskProfile.metadata.requirements).toEqual(['idasen-ha==2.6.5']); + + const runtime = await integration.setup({ name: 'Desk Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'idasen_desk', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'idasen_desk', service: 'refresh', target: {} }); + const snapshot = status.data as IIdasenDeskSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Bedroom Desk'); + + const command = await runtime.callService!({ domain: 'cover', service: 'open_cover', target: { entityId: 'cover.bedroom_desk_desk' } }); + 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/idteck_prox/test.idteck_prox.node.ts b/test/idteck_prox/test.idteck_prox.node.ts new file mode 100644 index 0000000..638568e --- /dev/null +++ b/test/idteck_prox/test.idteck_prox.node.ts @@ -0,0 +1,80 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIdteckProxIntegration, IdteckProxClient, IdteckProxConfigFlow, IdteckProxIntegration, IdteckProxMapper, createIdteckProxDiscoveryDescriptor, idteckProxProfile, type IIdteckProxSnapshot, type TIdteckProxRawData } from '../../ts/integrations/idteck_prox/index.js'; + +const rawData: TIdteckProxRawData = { + device: { + id: 'idteck-front-door', + name: 'Front Door Reader', + manufacturer: 'IDTECK', + model: 'RFK101 proximity reader', + host: '192.0.2.11', + port: 9000, + protocol: 'tcp', + }, + entities: [ + { id: 'last_card', name: 'Last Card', platform: 'sensor', state: '1234567890', attributes: { event: 'idteck_prox_keycard' } }, + { id: 'reader_name', name: 'Reader Name', platform: 'sensor', state: 'Front Door Reader' }, + { id: 'event_count', name: 'Event Count', platform: 'sensor', state: 7, stateClass: 'measurement' }, + { id: 'connected', name: 'Connected', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' }, + ], +}; + +tap.test('matches manual IDTECK Prox candidates and creates config flow output', async () => { + const descriptor = createIdteckProxDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'idteck_prox-manual-match'); + const result = await matcher!.matches({ id: 'front-door-reader', host: '192.0.2.11', port: 9000, name: 'Front Door Reader', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('idteck_prox'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IdteckProxConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.11'); + expect(done.config?.port).toEqual(9000); +}); + +tap.test('maps IDTECK Prox raw snapshots to runtime devices and entities', async () => { + const client = new IdteckProxClient({ name: 'IDTECK Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IdteckProxMapper.toSnapshotFromRaw({ name: 'IDTECK Runtime' }, rawData); + const devices = IdteckProxMapper.toDevices(mappedSnapshot); + const entities = IdteckProxMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('idteck_prox'); + expect(devices[0].manufacturer).toEqual('IDTECK'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.front_door_reader_last_card')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.front_door_reader_connected')).toBeTrue(); +}); + +tap.test('exposes IDTECK Prox read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IdteckProxIntegration(); + const alias = new HomeAssistantIdteckProxIntegration(); + expect(alias instanceof IdteckProxIntegration).toBeTrue(); + expect(alias.domain).toEqual('idteck_prox'); + expect(integration.status).toEqual('read-only-runtime'); + expect(idteckProxProfile.metadata.configFlow).toEqual(false); + expect(idteckProxProfile.metadata.qualityScale).toEqual('legacy'); + expect(idteckProxProfile.metadata.requirements).toEqual(['rfk101py==0.0.1']); + + const runtime = await integration.setup({ name: 'IDTECK Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'idteck_prox', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'idteck_prox', service: 'refresh', target: {} }); + const snapshot = status.data as IIdteckProxSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Front Door Reader'); + + const command = await runtime.callService!({ domain: 'idteck_prox', 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/iglo/test.iglo.node.ts b/test/iglo/test.iglo.node.ts new file mode 100644 index 0000000..8e12583 --- /dev/null +++ b/test/iglo/test.iglo.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIgloIntegration, IgloClient, IgloConfigFlow, IgloIntegration, IgloMapper, createIgloDiscoveryDescriptor, igloDefaultPort, igloProfile, type IIgloSnapshot, type TIgloRawData } from '../../ts/integrations/iglo/index.js'; + +const rawData: TIgloRawData = { + device: { + id: 'iglo-kitchen', + name: 'Kitchen iGlo', + manufacturer: 'iGlo', + model: 'iGlo Light', + host: '192.0.2.21', + port: igloDefaultPort, + attributes: { + lightId: 0, + }, + }, + entities: [ + { id: 'light', name: 'Light', platform: 'light', state: true, writable: true, attributes: { brightness: 153, brightnessScale: 255, colorMode: 'hs', hsColor: [32, 76], colorTempKelvin: 3200, effect: 'Rainbow', effectList: ['Rainbow', 'Pulse'] } }, + ], +}; + +tap.test('matches manual iGlo candidates and creates config flow output', async () => { + const descriptor = createIgloDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iglo-manual-match'); + const result = await matcher!.matches({ id: 'iglo-kitchen', host: '192.0.2.21', port: igloDefaultPort, name: 'Kitchen iGlo', metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('iglo'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IgloConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.0.2.21'); + expect(done.config?.port).toEqual(igloDefaultPort); +}); + +tap.test('maps iGlo raw snapshots to runtime devices and entities', async () => { + const client = new IgloClient({ name: 'iGlo Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IgloMapper.toSnapshotFromRaw({ name: 'iGlo Runtime' }, rawData); + const devices = IgloMapper.toDevices(mappedSnapshot); + const entities = IgloMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('iglo'); + expect(devices[0].manufacturer).toEqual('iGlo'); + expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_iglo_light')).toBeTrue(); + expect(entities[0].attributes?.effect).toEqual('Rainbow'); +}); + +tap.test('exposes iGlo read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IgloIntegration(); + const alias = new HomeAssistantIgloIntegration(); + expect(alias instanceof IgloIntegration).toBeTrue(); + expect(alias.domain).toEqual('iglo'); + expect(integration.status).toEqual('read-only-runtime'); + expect(igloProfile.metadata.configFlow).toEqual(false); + expect(igloProfile.metadata.qualityScale).toEqual('legacy'); + expect(igloProfile.metadata.requirements).toEqual(['iglo==1.2.7']); + + const runtime = await integration.setup({ name: 'iGlo Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'iglo', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'iglo', service: 'refresh', target: {} }); + const snapshot = status.data as IIgloSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Kitchen iGlo'); + + const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.kitchen_iglo_light' }, data: { brightness: 128 } }); + 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/ihc/test.ihc.node.ts b/test/ihc/test.ihc.node.ts new file mode 100644 index 0000000..d645373 --- /dev/null +++ b/test/ihc/test.ihc.node.ts @@ -0,0 +1,83 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIhcIntegration, IhcClient, IhcConfigFlow, IhcIntegration, IhcMapper, createIhcDiscoveryDescriptor, ihcProfile, type IIhcSnapshot, type TIhcRawData } from '../../ts/integrations/ihc/index.js'; + +const rawData: TIhcRawData = { + device: { + id: 'ihc-controller-123456', + name: 'Main IHC Controller', + manufacturer: 'Schneider Electric', + model: 'IHC Controller', + serialNumber: '123456', + attributes: { + url: 'http://ihc-controller.local', + controllerId: '123456', + }, + }, + entities: [ + { id: 'motion_1001', name: 'Hall Motion', platform: 'binary_sensor', state: true, deviceClass: 'motion', attributes: { ihcId: 1001 } }, + { id: 'light_2001', name: 'Hall Light', platform: 'light', state: true, writable: true, attributes: { ihcId: 2001, brightness: 180 } }, + { id: 'temperature_3001', name: 'Living Room Temperature', platform: 'sensor', state: 21.4, unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', attributes: { ihcId: 3001 } }, + { id: 'switch_4001', name: 'Pump Switch', platform: 'switch', state: false, writable: true, attributes: { ihcId: 4001 } }, + ], +}; + +tap.test('matches manual IHC candidates and creates config flow output', async () => { + const descriptor = createIhcDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ihc-manual-match'); + const result = await matcher!.matches({ id: 'ihc-controller-123456', name: 'Main IHC Controller', metadata: { url: 'http://ihc-controller.local', rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('ihc'); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IhcConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('ihc-controller-123456'); + expect(done.config?.metadata?.url).toEqual('http://ihc-controller.local'); +}); + +tap.test('maps IHC raw snapshots to runtime devices and entities', async () => { + const client = new IhcClient({ name: 'IHC Runtime', rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IhcMapper.toSnapshotFromRaw({ name: 'IHC Runtime' }, rawData); + const devices = IhcMapper.toDevices(mappedSnapshot); + const entities = IhcMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual('ihc'); + expect(devices[0].manufacturer).toEqual('Schneider Electric'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.main_ihc_controller_motion_1001')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.main_ihc_controller_light_2001')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.main_ihc_controller_switch_4001')).toBeTrue(); +}); + +tap.test('exposes IHC read-only runtime, HA alias, and unsupported control', async () => { + const integration = new IhcIntegration(); + const alias = new HomeAssistantIhcIntegration(); + expect(alias instanceof IhcIntegration).toBeTrue(); + expect(alias.domain).toEqual('ihc'); + expect(integration.status).toEqual('read-only-runtime'); + expect(ihcProfile.metadata.configFlow).toEqual(false); + expect(ihcProfile.metadata.qualityScale).toEqual('legacy'); + expect(ihcProfile.metadata.requirements).toEqual(['defusedxml==0.7.1', 'ihcsdk==2.8.5']); + + const runtime = await integration.setup({ name: 'IHC Runtime', rawData }, {}); + const status = await runtime.callService!({ domain: 'ihc', service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: 'ihc', service: 'refresh', target: {} }); + const snapshot = status.data as IIhcSnapshot; + + expect(status.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual('Main IHC Controller'); + + const command = await runtime.callService!({ domain: 'ihc', service: 'set_runtime_value_bool', target: {}, data: { ihc_id: 2001, value: true } }); + 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/imeon_inverter/test.imeon_inverter.client.node.ts b/test/imeon_inverter/test.imeon_inverter.client.node.ts new file mode 100644 index 0000000..f178a27 --- /dev/null +++ b/test/imeon_inverter/test.imeon_inverter.client.node.ts @@ -0,0 +1,106 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ImeonInverterClient, ImeonInverterIntegration, imeonInverterProfile, type IImeonInverterSnapshot } from '../../ts/integrations/imeon_inverter/index.js'; + +tap.test('logs in, polls live Imeon HTTP endpoints, and maps HA sensor/select keys', async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; method: string; cookie?: string; body?: unknown }> = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const url = new URL(String(inputArg)); + const headers = initArg?.headers as Record | undefined; + requests.push({ url: url.toString(), method: initArg?.method || 'GET', cookie: headers?.cookie, body: initArg?.body }); + + if (url.pathname === '/login') { + expect(String(initArg?.body)).toContain('email=user%40local'); + return jsonResponse({ accessGranted: true }, 200, { 'set-cookie': 'session=abc; Path=/' }); + } + if (url.pathname === '/data') { + return jsonResponse({ type: 'Imeon 9.12', software: '1.8.1.0', serial: 'IMEON123', max_ac_charging_current: 16, injection_power: 2500, enable_status: { discharge_night: '1', charge_bat_with_grid: '0' } }); + } + if (url.pathname === '/api/battery') { + return imeonResult({ power: 123, soc: 88, status: 'charging', stored: 20, consumed: 10 }); + } + if (url.pathname === '/api/grid') { + expect(url.searchParams.get('threephase')).toEqual('true'); + return imeonResult({ current_l1: 1, current_l2: 2, current_l3: 3, frequency: 50, voltage_l1: 230, voltage_l2: 231, voltage_l3: 232 }); + } + if (url.pathname === '/api/pv') { + return imeonResult({ consumed: 4, injected: 5, power_1: 100, power_2: 110, power_total: 210 }); + } + if (url.pathname === '/api/input') { + return imeonResult({ power_l1: 1, power_l2: 2, power_l3: 3, power_total: 6 }); + } + if (url.pathname === '/api/output') { + return imeonResult({ current_l1: 1, current_l2: 2, current_l3: 3, frequency: 50, power_l1: 10, power_l2: 20, power_l3: 30, power_total: 60, voltage_l1: 230, voltage_l2: 231, voltage_l3: 232 }); + } + if (url.pathname === '/api/em') { + return imeonResult({ power: 42 }); + } + if (url.pathname === '/api/temp') { + return imeonResult({ air_temperature: 21, component_temperature: 33 }); + } + if (url.pathname === '/api/timeline') { + return imeonResult({ type_msg: 'good_1' }); + } + if (url.pathname === '/api/energy') { + return imeonResult({ pv: 1000, grid_injected: 200, grid_consumed: 300, building_consumption: 400, battery_stored: 500, battery_consumed: 600 }); + } + if (url.pathname === '/api/forecast') { + return imeonResult({ cons_remaining_today: 700, prod_remaining_today: 800 }); + } + if (url.pathname === '/api/monitor') { + return url.searchParams.get('time') === 'minute' + ? imeonResult({ building_consumption: 111, grid_consumption: 222, grid_injection: 333, grid_power_flow: 444, solar_production: 555 }) + : imeonResult({ self_consumption: 67, self_sufficiency: 89 }); + } + if (url.pathname === '/api/manager') { + return imeonResult({ inverter_state: 'grid_consumption', inverter_mode: 'smg' }); + } + if (url.pathname === '/api/smartload') { + return imeonResult({ active: false }); + } + if (url.pathname === '/api/set') { + expect(initArg?.body instanceof FormData).toBeTrue(); + expect((initArg?.body as FormData).get('inverter_mode')).toEqual('bup'); + return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return jsonResponse({ message: 'not found' }, 404); + }) as typeof globalThis.fetch; + + try { + const client = new ImeonInverterClient({ host: 'imeon.local', username: 'user@local', password: 'password' }); + const snapshot = await client.getSnapshot(); + const command = await client.execute({ domain: 'select', service: 'select_option', target: {}, data: { option: 'backup' } }); + const runtime = await new ImeonInverterIntegration().setup({ host: 'imeon.local', username: 'user@local', password: 'password' }, {}); + const status = await runtime.callService!({ domain: 'imeon_inverter', service: 'status', target: {} }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('IMEON123'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'battery_power')?.state).toEqual(123); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'manager_inverter_mode')?.state).toEqual('smart_grid'); + expect(command.success).toBeTrue(); + expect((status.data as IImeonInverterSnapshot).online).toBeTrue(); + expect(requests.some((requestArg) => requestArg.url.includes('/api/battery?time=minute') && requestArg.cookie === 'session=abc')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.url.includes('/api/set'))).toBeTrue(); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('documents Imeon native HTTP support and safe unsupported cases', async () => { + const localApi = imeonInverterProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] }; + + expect(localApi.implemented.some((itemArg) => itemArg.includes('POST /login'))).toBeTrue(); + expect(localApi.implemented.some((itemArg) => itemArg.includes('POST /api/set'))).toBeTrue(); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('ETH port access only'))).toBeTrue(); +}); + +const imeonResult = (valueArg: Record): Response => jsonResponse({ result: JSON.stringify(valueArg) }); + +const jsonResponse = (valueArg: unknown, statusArg = 200, headersArg: Record = {}): Response => new Response(JSON.stringify(valueArg), { + status: statusArg, + headers: { 'content-type': 'application/json', ...headersArg }, +}); + +export default tap.start(); diff --git a/test/imeon_inverter/test.imeon_inverter.node.ts b/test/imeon_inverter/test.imeon_inverter.node.ts new file mode 100644 index 0000000..d245b17 --- /dev/null +++ b/test/imeon_inverter/test.imeon_inverter.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ImeonInverterClient, ImeonInverterConfigFlow, ImeonInverterIntegration, ImeonInverterMapper, createImeonInverterDiscoveryDescriptor, imeonInverterProfile, type IImeonInverterSnapshot, type TImeonInverterRawData } from '../../ts/integrations/imeon_inverter/index.js'; + +const rawData: TImeonInverterRawData = { + device: { + id: 'imeon_inverter-device-1', + name: "Imeon Inverter Device", + manufacturer: "Imeon Inverter", + model: "Imeon Inverter local integration", + serialNumber: 'imeon_inverter-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "select", state: true, attributes: { domain: "imeon_inverter" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Imeon Inverter candidates and creates config flow output', async () => { + const descriptor = createImeonInverterDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'imeon_inverter-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'imeon_inverter-device-1', name: "Imeon Inverter Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("imeon_inverter"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new ImeonInverterConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('imeon_inverter-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Imeon Inverter raw snapshots to runtime devices and entities', async () => { + const client = new ImeonInverterClient({ name: "Imeon Inverter Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = ImeonInverterMapper.toSnapshotFromRaw({ name: "Imeon Inverter Runtime" }, rawData); + const devices = ImeonInverterMapper.toDevices(mappedSnapshot); + const entities = ImeonInverterMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("imeon_inverter"); + expect(devices[0].manufacturer).toEqual("Imeon Inverter"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "imeon_inverter" && entityArg.platform === "select")).toBeTrue(); +}); + +tap.test('exposes Imeon Inverter runtime and unsupported control without executor', async () => { + const integration = new ImeonInverterIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(imeonInverterProfile.metadata.configFlow).toEqual(true); + expect(imeonInverterProfile.metadata.requirements).toEqual([ + "imeon_inverter_api==0.4.0", + ]); + + const runtime = await integration.setup({ name: "Imeon Inverter Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "imeon_inverter", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "imeon_inverter", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IImeonInverterSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Imeon Inverter Device"); + + const command = await runtime.callService!({ domain: "imeon_inverter", service: imeonInverterProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('select_option requires config.host/config.url plus config.username and config.password'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/immich/test.immich.client.node.ts b/test/immich/test.immich.client.node.ts new file mode 100644 index 0000000..d13772f --- /dev/null +++ b/test/immich/test.immich.client.node.ts @@ -0,0 +1,103 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ImmichClient, ImmichIntegration, immichProfile, type IImmichSnapshot } from '../../ts/integrations/immich/index.js'; + +tap.test('polls live Immich server data through the local HTTP API', async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; method: string; apiKey?: string }> = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const url = new URL(String(inputArg)); + const headers = initArg?.headers as Record; + requests.push({ url: url.toString(), method: initArg?.method || 'GET', apiKey: headers?.['x-api-key'] }); + + if (url.pathname === '/api/users/me') { + return jsonResponse({ id: 'user-1', name: 'Ada', email: 'ada@example.test', isAdmin: true }); + } + if (url.pathname === '/api/server/about') { + return jsonResponse({ version: 'v1.134.1', licensed: true, versionUrl: 'https://example.test/version' }); + } + if (url.pathname === '/api/server/storage') { + return jsonResponse({ diskAvailableRaw: 600, diskAvailable: '600 B', diskSizeRaw: 1000, diskSize: '1000 B', diskUsagePercentage: 40, diskUseRaw: 400, diskUse: '400 B' }); + } + if (url.pathname === '/api/server/statistics') { + return jsonResponse({ photos: 12, videos: 3, usage: 400, usagePhotos: 250, usageVideos: 150, usageByUser: [] }); + } + if (url.pathname === '/api/server/version-check') { + return jsonResponse({ checkedAt: '2026-05-11T00:00:00.000Z', releaseVersion: 'v1.135.0' }); + } + return jsonResponse({ message: 'not found' }, 404); + }) as typeof globalThis.fetch; + + try { + const client = new ImmichClient({ url: 'http://immich.local:2283', apiKey: 'secret' }); + const snapshot = await client.getSnapshot(); + const runtime = await new ImmichIntegration().setup({ url: 'http://immich.local:2283', apiKey: 'secret' }, {}); + const status = await runtime.callService!({ domain: 'immich', service: 'status', target: {} }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.name).toEqual('Ada'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'disk_size')?.state).toEqual(1000); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'photos_count')?.state).toEqual(12); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'update')?.attributes?.latestVersion).toEqual('v1.135.0'); + expect((status.data as IImmichSnapshot).online).toBeTrue(); + expect(requests.every((requestArg) => requestArg.apiKey === 'secret')).toBeTrue(); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('uploads a local file to Immich and adds it to an album', async () => { + const originalFetch = globalThis.fetch; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'immich-client-')); + const filePath = path.join(tempDir, 'image.jpg'); + await fs.writeFile(filePath, Buffer.from([1, 2, 3])); + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const url = new URL(String(inputArg)); + requests.push({ path: url.pathname, method: initArg?.method || 'GET', body: initArg?.body }); + if (url.pathname === '/api/albums/album-1') { + expect(url.searchParams.get('withoutAssets')).toEqual('true'); + return jsonResponse({ id: 'album-1', albumName: 'Uploads' }); + } + if (url.pathname === '/api/assets') { + expect(initArg?.body instanceof FormData).toBeTrue(); + expect((initArg?.body as FormData).get('assetData')).toBeTruthy(); + return jsonResponse({ id: 'asset-1', status: 'created' }); + } + if (url.pathname === '/api/albums/album-1/assets') { + expect(String(initArg?.body)).toEqual(JSON.stringify({ ids: ['asset-1'] })); + return jsonResponse([{ id: 'asset-1', success: true }]); + } + return jsonResponse({ message: 'not found' }, 404); + }) as typeof globalThis.fetch; + + try { + const client = new ImmichClient({ host: '127.0.0.1', port: 2283, apiKey: 'secret' }); + const result = await client.execute({ domain: 'immich', service: 'upload_file', target: {}, data: { path: filePath, album_id: 'album-1' } }); + + expect(result.success).toBeTrue(); + expect(requests.map((requestArg) => `${requestArg.method} ${requestArg.path}`)).toEqual(['GET /api/albums/album-1', 'POST /api/assets', 'PUT /api/albums/album-1/assets']); + } finally { + globalThis.fetch = originalFetch; + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +tap.test('documents Immich local API support and unsupported TLS bypass', async () => { + const localApi = immichProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] }; + + expect(localApi.implemented.some((itemArg) => itemArg.includes('/api/server/about'))).toBeTrue(); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('TLS certificate verification'))).toBeTrue(); +}); + +const jsonResponse = (valueArg: unknown, statusArg = 200): Response => new Response(JSON.stringify(valueArg), { + status: statusArg, + headers: { 'content-type': 'application/json' }, +}); + +export default tap.start(); diff --git a/test/immich/test.immich.node.ts b/test/immich/test.immich.node.ts new file mode 100644 index 0000000..cb62d61 --- /dev/null +++ b/test/immich/test.immich.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ImmichClient, ImmichConfigFlow, ImmichIntegration, ImmichMapper, createImmichDiscoveryDescriptor, immichProfile, type IImmichSnapshot, type TImmichRawData } from '../../ts/integrations/immich/index.js'; + +const rawData: TImmichRawData = { + device: { + id: 'immich-device-1', + name: "Immich Device", + manufacturer: "Immich", + model: "Immich local integration", + serialNumber: 'immich-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "immich" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Immich candidates and creates config flow output', async () => { + const descriptor = createImmichDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'immich-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'immich-device-1', name: "Immich Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("immich"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new ImmichConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('immich-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Immich raw snapshots to runtime devices and entities', async () => { + const client = new ImmichClient({ name: "Immich Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = ImmichMapper.toSnapshotFromRaw({ name: "Immich Runtime" }, rawData); + const devices = ImmichMapper.toDevices(mappedSnapshot); + const entities = ImmichMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("immich"); + expect(devices[0].manufacturer).toEqual("Immich"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "immich" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes Immich runtime and unsupported control without executor', async () => { + const integration = new ImmichIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(immichProfile.metadata.configFlow).toEqual(true); + expect(immichProfile.metadata.requirements).toEqual([ + "aioimmich==0.14.0", + ]); + + const runtime = await integration.setup({ name: "Immich Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "immich", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "immich", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IImmichSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Immich Device"); + + const command = await runtime.callService!({ domain: "immich", service: immichProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('upload_file requires config.url or config.host plus config.apiKey/config.token'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/improv_ble/test.improv_ble.node.ts b/test/improv_ble/test.improv_ble.node.ts new file mode 100644 index 0000000..e516ecc --- /dev/null +++ b/test/improv_ble/test.improv_ble.node.ts @@ -0,0 +1,18 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ImprovBleClient, improvBleProfile } from '../../ts/integrations/improv_ble/index.js'; + +tap.test('keeps Improv BLE to safe snapshot inputs and documents the BLE stack blocker', async () => { + const localApi = improvBleProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] }; + const client = new ImprovBleClient({ rawData: { device: { name: 'Improv Fixture' }, entities: [{ id: 'state', name: 'State', state: 'authorized' }] } }); + const snapshot = await client.getSnapshot(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('manual'); + expect(snapshot.entities[0].state).toEqual('authorized'); + expect(improvBleProfile.discoverySources).toContain('bluetooth'); + expect(localApi.implemented.some((itemArg) => itemArg.includes('safe manual snapshots'))).toBeTrue(); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('py-improv-ble-client') && itemArg.includes('BLE stack'))).toBeTrue(); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('Wi-Fi credential pairing'))).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/incomfort/test.incomfort.node.ts b/test/incomfort/test.incomfort.node.ts new file mode 100644 index 0000000..623230c --- /dev/null +++ b/test/incomfort/test.incomfort.node.ts @@ -0,0 +1,163 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IncomfortClient, IncomfortConfigFlow, IncomfortIntegration, IncomfortMapper, createIncomfortDiscoveryDescriptor, incomfortProfile, type IIncomfortSnapshot, type TIncomfortRawData } from '../../ts/integrations/incomfort/index.js'; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const heaterPayload = { + displ_code: 126, + IO: 0x02, + ch_temp_msb: 12, + ch_temp_lsb: 48, + tap_temp_msb: 11, + tap_temp_lsb: 27, + ch_pressure_msb: 0, + ch_pressure_lsb: 123, + room_temp_1_msb: 8, + room_temp_1_lsb: 64, + room_temp_set_1_msb: 7, + room_temp_set_1_lsb: 158, + room_set_ovr_1_msb: 7, + room_set_ovr_1_lsb: 208, + room_temp_2_msb: 127, + room_temp_2_lsb: 255, + rf_message_rssi: 38, + rfstatus_cntr: 0, + nodenr: 200, +}; + +const startIncomfortServer = async (): Promise<{ url: string; setpointCalls: string[]; close(): Promise }> => { + const setpointCalls: string[] = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + + if (url.pathname === '/heaterlist.json') { + json(responseArg, { heaterlist: ['175t23072', '000W00000', null] }); + return; + } + if (url.pathname === '/data.json' && url.searchParams.get('heater') === '0') { + if (url.searchParams.has('setpoint')) { + setpointCalls.push(url.search); + } + json(responseArg, heaterPayload); + return; + } + + json(responseArg, {}, 404); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + setpointCalls, + close: async () => new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())), + }; +}; + +const rawData: TIncomfortRawData = { + device: { + id: 'incomfort-device-1', + name: "Intergas gateway Device", + manufacturer: "Intergas gateway", + model: "Intergas gateway local integration", + serialNumber: 'incomfort-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "incomfort" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Intergas gateway candidates and creates config flow output', async () => { + const descriptor = createIncomfortDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'incomfort-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'incomfort-device-1', name: "Intergas gateway Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("incomfort"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IncomfortConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('incomfort-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Intergas gateway raw snapshots to runtime devices and entities', async () => { + const client = new IncomfortClient({ name: "Intergas gateway Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IncomfortMapper.toSnapshotFromRaw({ name: "Intergas gateway Runtime" }, rawData); + const devices = IncomfortMapper.toDevices(mappedSnapshot); + const entities = IncomfortMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("incomfort"); + expect(devices[0].manufacturer).toEqual("Intergas gateway"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "incomfort" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads InComfort gateway snapshots and writes thermostat setpoints over local HTTP', async () => { + const server = await startIncomfortServer(); + try { + const endpoint = new URL(server.url); + const client = new IncomfortClient({ host: endpoint.hostname, port: Number(endpoint.port), timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const entities = IncomfortMapper.toEntities(snapshot); + const climate = entities.find((entityArg) => entityArg.platform === 'climate'); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.manufacturer).toEqual('Intergas'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.intergas_gateway_heater_0_cv_pressure')?.state).toEqual(1.23); + expect(climate?.attributes?.heaterIndex).toEqual(0); + expect(climate?.attributes?.roomNumber).toEqual(1); + + const runtime = await new IncomfortIntegration().setup({ host: endpoint.hostname, port: Number(endpoint.port), timeoutMs: 1000 }, {}); + const result = await runtime.callService?.({ domain: 'climate', service: 'set_temperature', target: { entityId: climate?.id }, data: { temperature: 19.5 } }); + + expect(result?.success).toBeTrue(); + expect(server.setpointCalls[0]).toContain('heater=0'); + expect(server.setpointCalls[0]).toContain('thermostat=0'); + expect(server.setpointCalls[0]).toContain('setpoint=145'); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +tap.test('exposes Intergas gateway runtime and unsupported control without executor', async () => { + const integration = new IncomfortIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(incomfortProfile.metadata.configFlow).toEqual(true); + expect(incomfortProfile.metadata.requirements).toEqual([ + "incomfort-client==0.7.0", + ]); + + const runtime = await integration.setup({ name: "Intergas gateway Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "incomfort", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "incomfort", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIncomfortSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Intergas gateway Device"); + + const command = await runtime.callService!({ domain: "incomfort", service: incomfortProfile.controlServices?.[0] || '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/indevolt/test.indevolt.node.ts b/test/indevolt/test.indevolt.node.ts new file mode 100644 index 0000000..95dde45 --- /dev/null +++ b/test/indevolt/test.indevolt.node.ts @@ -0,0 +1,125 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IndevoltClient, IndevoltConfigFlow, IndevoltIntegration, IndevoltMapper, createIndevoltDiscoveryDescriptor, indevoltProfile, type IIndevoltSnapshot, type TIndevoltRawData } from '../../ts/integrations/indevolt/index.js'; + +const liveSensorKeys = ['7101', '2101', '6002', '2618', '6105']; + +const rawData: TIndevoltRawData = { + device: { + id: 'indevolt-device-1', + name: "Indevolt Device", + manufacturer: "Indevolt", + model: "Indevolt local integration", + serialNumber: 'indevolt-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "indevolt" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Indevolt candidates and creates config flow output', async () => { + const descriptor = createIndevoltDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'indevolt-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'indevolt-device-1', name: "Indevolt Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("indevolt"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IndevoltConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('indevolt-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Indevolt raw snapshots to runtime devices and entities', async () => { + const client = new IndevoltClient({ name: "Indevolt Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IndevoltMapper.toSnapshotFromRaw({ name: "Indevolt Runtime" }, rawData); + const devices = IndevoltMapper.toDevices(mappedSnapshot); + const entities = IndevoltMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("indevolt"); + expect(devices[0].manufacturer).toEqual("Indevolt"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "indevolt" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads Indevolt local HTTP RPC snapshots and executes realtime charge commands', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method?: string; config?: Record }> = []; + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + const url = new URL(String(urlArg)); + const config = url.searchParams.get('config') ? JSON.parse(url.searchParams.get('config')!) as Record : undefined; + calls.push({ url: String(urlArg), method: initArg?.method, config }); + + if (url.pathname === '/rpc/Sys.GetConfig') { + return new Response(JSON.stringify({ device: { sn: 'INVOLT123', type: 'CMS-SP2000', fw: '1.2.3' } }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (url.pathname === '/rpc/Indevolt.GetData') { + expect(config?.t).toEqual(liveSensorKeys.map((keyArg) => Number(keyArg))); + return new Response(JSON.stringify({ '7101': 1, '2101': 500, '6002': 74, '2618': 1000, '6105': 20 }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (url.pathname === '/rpc/Indevolt.SetData') { + return new Response(JSON.stringify({ result: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + + return new Response('{}', { status: 404, headers: { 'content-type': 'application/json' } }); + }) as typeof globalThis.fetch; + + try { + const config = { host: '192.0.2.10', port: 8080, sensorKeys: liveSensorKeys, timeoutMs: 1000 }; + const client = new IndevoltClient(config); + const snapshot = await client.getSnapshot(true); + const entities = IndevoltMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('INVOLT123'); + expect(entities.find((entityArg) => entityArg.id === 'select.indevolt_cms_sp2000_energy_mode')?.state).toEqual('self_consumed_prioritized'); + expect(entities.find((entityArg) => entityArg.id === 'switch.indevolt_cms_sp2000_grid_charging')?.state).toEqual(false); + + const runtime = await new IndevoltIntegration().setup(config, {}); + await runtime.entities(); + const result = await runtime.callService?.({ domain: 'indevolt', service: 'charge', target: {}, data: { power: 700, target_soc: 80 } }); + const setDataCalls = calls.filter((callArg) => new URL(callArg.url).pathname === '/rpc/Indevolt.SetData'); + + expect(result?.success).toBeTrue(); + expect(setDataCalls[0].config).toEqual({ f: 16, t: 47005, v: [4] }); + expect(setDataCalls[1].config).toEqual({ f: 16, t: 47015, v: [1, 700, 80] }); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Indevolt runtime and unsupported control without executor', async () => { + const integration = new IndevoltIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(indevoltProfile.metadata.configFlow).toEqual(true); + expect(indevoltProfile.metadata.requirements).toEqual([ + "indevolt-api==1.7.1", + ]); + + const runtime = await integration.setup({ name: "Indevolt Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "indevolt", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "indevolt", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIndevoltSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Indevolt Device"); + + const command = await runtime.callService!({ domain: "indevolt", service: indevoltProfile.controlServices?.[0] || '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/inels/test.inels.node.ts b/test/inels/test.inels.node.ts new file mode 100644 index 0000000..2ff82c8 --- /dev/null +++ b/test/inels/test.inels.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { InelsClient, InelsConfigFlow, InelsIntegration, InelsMapper, createInelsDiscoveryDescriptor, inelsProfile, type IInelsSnapshot, type TInelsRawData } from '../../ts/integrations/inels/index.js'; + +const rawData: TInelsRawData = { + device: { + id: 'inels-device-1', + name: "iNELS Device", + manufacturer: "iNELS", + model: "iNELS local integration", + serialNumber: 'inels-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "inels" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual iNELS candidates and creates config flow output', async () => { + const descriptor = createInelsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'inels-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'inels-device-1', name: "iNELS Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("inels"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new InelsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('inels-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps iNELS raw snapshots to runtime devices and entities', async () => { + const client = new InelsClient({ name: "iNELS Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = InelsMapper.toSnapshotFromRaw({ name: "iNELS Runtime" }, rawData); + const devices = InelsMapper.toDevices(mappedSnapshot); + const entities = InelsMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("inels"); + expect(devices[0].manufacturer).toEqual("iNELS"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "inels" && entityArg.platform === "switch")).toBeTrue(); +}); + +tap.test('exposes iNELS runtime and unsupported control without executor', async () => { + const integration = new InelsIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(inelsProfile.metadata.configFlow).toEqual(true); + expect(inelsProfile.metadata.requirements).toEqual([ + "elkoep-aio-mqtt==0.1.0b4", + ]); + expect((inelsProfile.metadata.localApi as Record).status).toContain('MQTT broker semantics'); + expect(((inelsProfile.metadata.localApi as Record).explicitUnsupported as string[])[0]).toContain('elkoep-aio-mqtt/inelsmqtt'); + + const runtime = await integration.setup({ name: "iNELS Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "inels", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "inels", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IInelsSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("iNELS Device"); + + const command = await runtime.callService!({ domain: "inels", service: inelsProfile.controlServices?.[0] || '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/influxdb/test.influxdb.node.ts b/test/influxdb/test.influxdb.node.ts new file mode 100644 index 0000000..05acdeb --- /dev/null +++ b/test/influxdb/test.influxdb.node.ts @@ -0,0 +1,178 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantInfluxdbIntegration, InfluxdbClient, InfluxdbConfigFlow, InfluxdbIntegration, InfluxdbMapper, createInfluxdbDiscoveryDescriptor, influxdbProfile, type IInfluxdbSnapshot, type TInfluxdbRawData } from '../../ts/integrations/influxdb/index.js'; + +const rawData: TInfluxdbRawData = { + device: { + id: 'influxdb-device-1', + name: "InfluxDB Device", + manufacturer: "InfluxDB", + model: "InfluxDB local integration", + serialNumber: 'influxdb-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "influxdb" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual InfluxDB candidates and creates config flow output', async () => { + const descriptor = createInfluxdbDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'influxdb-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'influxdb-device-1', name: "InfluxDB Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("influxdb"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new InfluxdbConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('influxdb-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps InfluxDB raw snapshots to runtime devices and entities', async () => { + const client = new InfluxdbClient({ name: "InfluxDB Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = InfluxdbMapper.toSnapshotFromRaw({ name: "InfluxDB Runtime" }, rawData); + const devices = InfluxdbMapper.toDevices(mappedSnapshot); + const entities = InfluxdbMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("influxdb"); + expect(devices[0].manufacturer).toEqual("InfluxDB"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "influxdb" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes InfluxDB runtime, HA alias, and unsupported control without executor', async () => { + const integration = new InfluxdbIntegration(); + const alias = new HomeAssistantInfluxdbIntegration(); + expect(alias instanceof InfluxdbIntegration).toBeTrue(); + expect(alias.domain).toEqual("influxdb"); + expect(integration.status).toEqual("read-only-runtime"); + expect(influxdbProfile.metadata.configFlow).toEqual(true); + expect(influxdbProfile.metadata.requirements).toEqual([ + "influxdb==5.3.1", + "influxdb-client==1.50.0", + ]); + + const runtime = await integration.setup({ name: "InfluxDB Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "influxdb", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "influxdb", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IInfluxdbSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("InfluxDB Device"); + + const command = await runtime.callService!({ domain: "influxdb", service: influxdbProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +tap.test('reads InfluxDB v1 repositories and query sensors over local HTTP', async () => { + const requests: Array<{ url: string; authorization?: string }> = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + requests.push({ url: requestArg.url || '', authorization: requestArg.headers.authorization }); + responseArg.setHeader('content-type', 'application/json'); + + if (url.pathname === '/query' && url.searchParams.get('q') === 'SHOW DATABASES;') { + responseArg.end(JSON.stringify({ results: [{ series: [{ name: 'databases', columns: ['name'], values: [['home_assistant'], ['_internal']] }] }] })); + return; + } + + if (url.pathname === '/query' && url.searchParams.get('db') === 'home_assistant' && (url.searchParams.get('q') || '').startsWith('select mean(value)')) { + responseArg.end(JSON.stringify({ results: [{ series: [{ name: 'temperature', columns: ['time', 'value'], values: [['2026-01-01T00:00:00Z', 21.5]] }] }] })); + return; + } + + responseArg.statusCode = 404; + responseArg.end(JSON.stringify({ error: 'not found' })); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const config = { + host: '127.0.0.1', + port, + apiVersion: '1' as const, + database: 'home_assistant', + username: 'ha', + password: 'secret', + timeoutMs: 1000, + name: 'Local InfluxDB', + queries: [{ name: 'Mean Temperature', measurement: 'temperature', field: 'value', where: 'time > now() - 15m', unitOfMeasurement: 'C' }], + }; + const snapshot = await new InfluxdbClient(config).getSnapshot(true); + const entities = InfluxdbMapper.toEntities(snapshot); + + expect(requests[0].authorization).toEqual(`Basic ${Buffer.from('ha:secret').toString('base64')}`); + expect(requests.some((requestArg) => requestArg.url.includes('SHOW+DATABASES'))).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect((snapshot.rawData as { repositories: Array<{ name: string }> }).repositories.map((repositoryArg) => repositoryArg.name)).toEqual(['home_assistant', '_internal']); + expect(entities.find((entityArg) => entityArg.id === 'sensor.local_influxdb_mean_temperature')?.state).toEqual(21.5); + + const runtime = await new InfluxdbIntegration().setup(config, {}); + const status = await runtime.callService!({ domain: 'influxdb', service: 'status', target: {} }); + expect(status.success).toBeTrue(); + expect((status.data as IInfluxdbSnapshot).source).toEqual('http'); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('writes InfluxDB v2 line protocol over local HTTP', async () => { + const bodies: string[] = []; + const requests: string[] = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + requests.push(`${requestArg.method} ${url.pathname}?${url.searchParams.toString()}`); + const chunks: Buffer[] = []; + requestArg.on('data', (chunkArg) => chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg))); + requestArg.on('end', () => { + bodies.push(Buffer.concat(chunks).toString('utf8')); + expect(requestArg.headers.authorization).toEqual('Token token-123'); + if (requestArg.method === 'POST' && url.pathname === '/api/v2/write') { + expect(url.searchParams.get('org')).toEqual('home'); + expect(url.searchParams.get('bucket')).toEqual('Home Assistant'); + responseArg.statusCode = 204; + responseArg.end(); + return; + } + responseArg.statusCode = 404; + responseArg.end('not found'); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const result = await new InfluxdbClient({ host: '127.0.0.1', port, apiVersion: '2', token: 'token-123', organization: 'home', bucket: 'Home Assistant', timeoutMs: 1000 }).write({ + measurement: 'temperature', + tags: { entity_id: 'sensor.kitchen' }, + fields: { value: 21.5, state: '21.5' }, + }); + + expect(result.success).toBeTrue(); + expect(result.repository).toEqual('Home Assistant'); + expect(requests).toEqual(['POST /api/v2/write?org=home&bucket=Home+Assistant']); + expect(bodies[0]).toEqual('temperature,entity_id=sensor.kitchen value=21.5,state="21.5"'); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +export default tap.start(); diff --git a/test/inkbird/test.inkbird.node.ts b/test/inkbird/test.inkbird.node.ts new file mode 100644 index 0000000..004f777 --- /dev/null +++ b/test/inkbird/test.inkbird.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantInkbirdIntegration, InkbirdClient, InkbirdConfigFlow, InkbirdIntegration, InkbirdMapper, createInkbirdDiscoveryDescriptor, inkbirdProfile, type IInkbirdSnapshot, type TInkbirdRawData } from '../../ts/integrations/inkbird/index.js'; + +const rawData: TInkbirdRawData = { + device: { + id: 'inkbird-device-1', + name: "INKBIRD Device", + manufacturer: "INKBIRD", + model: "INKBIRD local integration", + serialNumber: 'inkbird-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "inkbird" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual INKBIRD candidates and creates config flow output', async () => { + const descriptor = createInkbirdDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'inkbird-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'inkbird-device-1', name: "INKBIRD Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("inkbird"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new InkbirdConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('inkbird-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps INKBIRD raw snapshots to runtime devices and entities', async () => { + const client = new InkbirdClient({ name: "INKBIRD Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = InkbirdMapper.toSnapshotFromRaw({ name: "INKBIRD Runtime" }, rawData); + const devices = InkbirdMapper.toDevices(mappedSnapshot); + const entities = InkbirdMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("inkbird"); + expect(devices[0].manufacturer).toEqual("INKBIRD"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "inkbird" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes INKBIRD runtime, HA alias, and unsupported control without executor', async () => { + const integration = new InkbirdIntegration(); + const alias = new HomeAssistantInkbirdIntegration(); + expect(alias instanceof InkbirdIntegration).toBeTrue(); + expect(alias.domain).toEqual("inkbird"); + expect(integration.status).toEqual("read-only-runtime"); + expect(inkbirdProfile.metadata.configFlow).toEqual(true); + expect(inkbirdProfile.metadata.requirements).toEqual([ + "inkbird-ble==1.1.1", + ]); + expect(((inkbirdProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('BLE stack'); + + const runtime = await integration.setup({ name: "INKBIRD Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "inkbird", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "inkbird", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IInkbirdSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("INKBIRD Device"); + + const command = await runtime.callService!({ domain: "inkbird", service: inkbirdProfile.controlServices?.[0] || '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/insteon/test.insteon.node.ts b/test/insteon/test.insteon.node.ts new file mode 100644 index 0000000..d472814 --- /dev/null +++ b/test/insteon/test.insteon.node.ts @@ -0,0 +1,81 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantInsteonIntegration, InsteonClient, InsteonConfigFlow, InsteonIntegration, InsteonMapper, createInsteonDiscoveryDescriptor, insteonProfile, type IInsteonSnapshot, type TInsteonRawData } from '../../ts/integrations/insteon/index.js'; + +const rawData: TInsteonRawData = { + device: { + id: 'insteon-device-1', + name: "Insteon Device", + manufacturer: "Insteon", + model: "Insteon local integration", + serialNumber: 'insteon-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "insteon" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Insteon candidates and creates config flow output', async () => { + const descriptor = createInsteonDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'insteon-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'insteon-device-1', name: "Insteon Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("insteon"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new InsteonConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('insteon-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Insteon raw snapshots to runtime devices and entities', async () => { + const client = new InsteonClient({ name: "Insteon Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = InsteonMapper.toSnapshotFromRaw({ name: "Insteon Runtime" }, rawData); + const devices = InsteonMapper.toDevices(mappedSnapshot); + const entities = InsteonMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("insteon"); + expect(devices[0].manufacturer).toEqual("Insteon"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "insteon" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('exposes Insteon runtime, HA alias, and unsupported control without executor', async () => { + const integration = new InsteonIntegration(); + const alias = new HomeAssistantInsteonIntegration(); + expect(alias instanceof InsteonIntegration).toBeTrue(); + expect(alias.domain).toEqual("insteon"); + expect(integration.status).toEqual("control-runtime"); + expect(insteonProfile.metadata.configFlow).toEqual(true); + expect(insteonProfile.metadata.requirements).toEqual([ + "pyinsteon==1.6.4", + "insteon-frontend-home-assistant==0.6.2", + ]); + expect(((insteonProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('pyinsteon'); + expect(((insteonProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('binary framing'); + + const runtime = await integration.setup({ name: "Insteon Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "insteon", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "insteon", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IInsteonSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Insteon Device"); + + const command = await runtime.callService!({ domain: "insteon", service: insteonProfile.controlServices?.[0] || '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/intellifire/test.intellifire.node.ts b/test/intellifire/test.intellifire.node.ts new file mode 100644 index 0000000..e4e9200 --- /dev/null +++ b/test/intellifire/test.intellifire.node.ts @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IntellifireClient, IntellifireConfigFlow, IntellifireIntegration, IntellifireMapper, createIntellifireDiscoveryDescriptor, intellifireProfile, type IIntellifireSnapshot, type TIntellifireRawData } from '../../ts/integrations/intellifire/index.js'; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const readBody = async (requestArg: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const intellifireResponse = (apiKeyArg: string, challengeArg: string, commandArg: string, valueArg: number): string => { + const apiBytes = Buffer.from(apiKeyArg, 'hex'); + const challengeBytes = Buffer.from(challengeArg, 'hex'); + const payloadBytes = Buffer.from(`post:command=${commandArg}&value=${valueArg}`, 'utf8'); + const inner = createHash('sha256').update(Buffer.concat([apiBytes, challengeBytes, payloadBytes])).digest(); + return createHash('sha256').update(Buffer.concat([apiBytes, inner])).digest('hex'); +}; + +const rawData: TIntellifireRawData = { + device: { + id: 'intellifire-device-1', + name: "IntelliFire Device", + manufacturer: "IntelliFire", + model: "IntelliFire local integration", + serialNumber: 'intellifire-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "intellifire" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual IntelliFire candidates and creates config flow output', async () => { + const descriptor = createIntellifireDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'intellifire-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'intellifire-device-1', name: "IntelliFire Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("intellifire"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IntellifireConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('intellifire-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps IntelliFire raw snapshots to runtime devices and entities', async () => { + const client = new IntellifireClient({ name: "IntelliFire Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IntellifireMapper.toSnapshotFromRaw({ name: "IntelliFire Runtime" }, rawData); + const devices = IntellifireMapper.toDevices(mappedSnapshot); + const entities = IntellifireMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("intellifire"); + expect(devices[0].manufacturer).toEqual("IntelliFire"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "intellifire" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads IntelliFire /poll over local HTTP and signs local /post controls', async () => { + const apiKey = '00112233445566778899aabbccddeeff'; + const challenge = 'abcdef0123456789'; + const bodies: string[] = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + void (async () => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + if (url.pathname === '/poll') { + json(responseArg, { + serial: 'IFT123456', + name: 'Living Room Fireplace', + power: 1, + pilot: 1, + timer: 0, + thermostat: 1, + temperature: 22, + setpoint: 2100, + height: 3, + fanspeed: 2, + light: 2, + feature_fan: 1, + feature_light: 1, + feature_thermostat: 1, + ipv4_address: '127.0.0.1', + errors: [6], + firmware_version: '0x01000000', + }); + return; + } + if (url.pathname === '/get_challenge') { + responseArg.statusCode = 200; + responseArg.setHeader('content-type', 'text/plain'); + responseArg.end(challenge); + return; + } + if (url.pathname === '/post' && requestArg.method === 'POST') { + const body = await readBody(requestArg); + bodies.push(body); + const params = new URLSearchParams(body); + expect(params.get('command')).toEqual('power'); + expect(params.get('value')).toEqual('0'); + expect(params.get('user')).toEqual('user-123'); + expect(params.get('response')).toEqual(intellifireResponse(apiKey, challenge, 'power', 0)); + json(responseArg, { ok: true }); + return; + } + json(responseArg, { error: 'not_found' }, 404); + })().catch((errorArg) => { + responseArg.statusCode = 500; + responseArg.end(String(errorArg)); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const url = `http://127.0.0.1:${port}`; + const client = new IntellifireClient({ url, userId: 'user-123', apiKey, timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.living_room_fireplace_flame' } }); + const runtime = await new IntellifireIntegration().setup({ url, timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('IFT123456'); + expect(entities.some((entityArg) => entityArg.attributes?.key === 'fanspeed' && entityArg.platform === 'fan')).toBeTrue(); + expect(command.success).toBeTrue(); + expect(bodies.length).toEqual(1); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes IntelliFire runtime and unsupported control without executor', async () => { + const integration = new IntellifireIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(intellifireProfile.metadata.configFlow).toEqual(true); + expect(intellifireProfile.metadata.requirements).toEqual([ + "intellifire4py==4.4.0", + ]); + + const runtime = await integration.setup({ name: "IntelliFire Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "intellifire", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "intellifire", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIntellifireSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeFalse(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("IntelliFire Device"); + + const command = await runtime.callService!({ domain: "switch", service: 'turn_on', target: { entityId: 'switch.intellifire_runtime_flame' } }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires config.host or config.url'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/iometer/test.iometer.node.ts b/test/iometer/test.iometer.node.ts new file mode 100644 index 0000000..0b90722 --- /dev/null +++ b/test/iometer/test.iometer.node.ts @@ -0,0 +1,142 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IometerClient, IometerConfigFlow, IometerIntegration, IometerMapper, createIometerDiscoveryDescriptor, iometerProfile, type IIometerSnapshot, type TIometerRawData } from '../../ts/integrations/iometer/index.js'; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const rawData: TIometerRawData = { + device: { + id: 'iometer-device-1', + name: "IOmeter Device", + manufacturer: "IOmeter", + model: "IOmeter local integration", + serialNumber: 'iometer-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "iometer" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual IOmeter candidates and creates config flow output', async () => { + const descriptor = createIometerDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iometer-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iometer-device-1', name: "IOmeter Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("iometer"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IometerConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('iometer-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps IOmeter raw snapshots to runtime devices and entities', async () => { + const client = new IometerClient({ name: "IOmeter Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IometerMapper.toSnapshotFromRaw({ name: "IOmeter Runtime" }, rawData); + const devices = IometerMapper.toDevices(mappedSnapshot); + const entities = IometerMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("iometer"); + expect(devices[0].manufacturer).toEqual("IOmeter"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "iometer" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads IOmeter /v1/reading and /v1/status over local HTTP', async () => { + const requests: Array<{ url?: string; userAgent?: string | string[] }> = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + requests.push({ url: url.pathname, userAgent: requestArg.headers['user-agent'] }); + if (url.pathname === '/v1/reading') { + json(responseArg, { + meter: { + number: 'METER123', + reading: { + time: '2026-01-01T00:00:00Z', + registers: [ + { obis: '01-00:01.08.00*ff', value: 12345, unit: 'Wh' }, + { obis: '01-00:02.08.00*ff', value: 234, unit: 'Wh' }, + { obis: '01-00:10.07.00*ff', value: 321, unit: 'W' }, + { obis: '01-00:01.08.01*ff', value: 10000, unit: 'Wh' }, + { obis: '01-00:01.08.02*ff', value: 2345, unit: 'Wh' }, + ], + }, + }, + }); + return; + } + if (url.pathname === '/v1/status') { + json(responseArg, { + meter: { number: 'METER123' }, + device: { + id: 'bridge-abc', + bridge: { rssi: -55, version: '1.2.3' }, + core: { connectionStatus: 'connected', rssi: -61, version: '2.3.4', powerStatus: 'wired', attachmentStatus: 'attached', pinStatus: 'entered', batteryLevel: 88 }, + }, + }); + return; + } + json(responseArg, { error: 'not_found' }, 404); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const url = `http://127.0.0.1:${port}`; + const client = new IometerClient({ url, timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new IometerIntegration().setup({ url, timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('bridge-abc'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.state).toEqual(321); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'connection_status')?.state).toBeTrue(); + expect(entities.some((entityArg) => entityArg.state === 321 && entityArg.attributes?.deviceClass === 'power')).toBeTrue(); + expect(requests.every((requestArg) => requestArg.userAgent === 'PythonIOmeter/0.1')).toBeTrue(); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes IOmeter runtime and unsupported control without executor', async () => { + const integration = new IometerIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(iometerProfile.metadata.configFlow).toEqual(true); + expect(iometerProfile.metadata.requirements).toEqual([ + "iometer==0.4.0", + ]); + + const runtime = await integration.setup({ name: "IOmeter Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "iometer", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "iometer", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIometerSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeFalse(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("IOmeter Device"); + + const command = await runtime.callService!({ domain: "iometer", service: iometerProfile.controlServices?.[0] || '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/iotawatt/test.iotawatt.node.ts b/test/iotawatt/test.iotawatt.node.ts new file mode 100644 index 0000000..ab00a22 --- /dev/null +++ b/test/iotawatt/test.iotawatt.node.ts @@ -0,0 +1,133 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IotawattClient, IotawattConfigFlow, IotawattIntegration, IotawattMapper, createIotawattDiscoveryDescriptor, iotawattProfile, type IIotawattSnapshot, type TIotawattRawData } from '../../ts/integrations/iotawatt/index.js'; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const rawData: TIotawattRawData = { + device: { + id: 'iotawatt-device-1', + name: "IoTaWatt Device", + manufacturer: "IoTaWatt", + model: "IoTaWatt local integration", + serialNumber: 'iotawatt-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iotawatt" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual IoTaWatt candidates and creates config flow output', async () => { + const descriptor = createIotawattDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iotawatt-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iotawatt-device-1', name: "IoTaWatt Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("iotawatt"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IotawattConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('iotawatt-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps IoTaWatt raw snapshots to runtime devices and entities', async () => { + const client = new IotawattClient({ name: "IoTaWatt Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IotawattMapper.toSnapshotFromRaw({ name: "IoTaWatt Runtime" }, rawData); + const devices = IotawattMapper.toDevices(mappedSnapshot); + const entities = IotawattMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("iotawatt"); + expect(devices[0].manufacturer).toEqual("IoTaWatt"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "iotawatt" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads IoTaWatt /status and /query over local HTTP', async () => { + const requests: string[] = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + requests.push(`${url.pathname}?${url.searchParams.toString()}`); + if (url.pathname === '/status' && url.searchParams.get('wifi') === 'yes') { + json(responseArg, { wifi: { mac: 'AA:BB:CC:DD:EE:FF' } }); + return; + } + if (url.pathname === '/status' && url.searchParams.get('inputs') === 'yes' && url.searchParams.get('outputs') === 'yes') { + json(responseArg, { inputs: [{ channel: 0 }], outputs: [{ name: 'Solar', units: 'Watts' }] }); + return; + } + if (url.pathname === '/query' && url.searchParams.get('show') === 'series') { + json(responseArg, { series: [{ name: 'Mains', unit: 'Watts' }] }); + return; + } + if (url.pathname === '/query' && url.searchParams.get('select') === '[Mains.watts,Solar.watts]') { + json(responseArg, [[42, -500]]); + return; + } + if (url.pathname === '/query' && url.searchParams.get('select') === '[time.iso,Mains.wh,Solar.wh]') { + json(responseArg, [['2026-01-01T00:00:00Z', 12345, 6789]]); + return; + } + json(responseArg, { error: 'not_found', select: url.searchParams.get('select') }, 404); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const url = `http://127.0.0.1:${port}`; + const client = new IotawattClient({ url, timeoutMs: 1000, timespanSeconds: 30 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new IotawattIntegration().setup({ url, timeoutMs: 1000, timespanSeconds: 30 }, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('AABBCCDDEEFF'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'input_0')?.state).toEqual(42); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'input_0_total_energy')?.state).toEqual(12345); + expect(entities.some((entityArg) => entityArg.state === -500 && entityArg.name === 'Solar')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.includes('show=series'))).toBeTrue(); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes IoTaWatt runtime and unsupported control without executor', async () => { + const integration = new IotawattIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(iotawattProfile.metadata.configFlow).toEqual(true); + expect(iotawattProfile.metadata.requirements).toEqual([ + "ha-iotawattpy==0.1.2", + ]); + + const runtime = await integration.setup({ name: "IoTaWatt Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "iotawatt", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "iotawatt", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIotawattSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeFalse(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("IoTaWatt Device"); + + const command = await runtime.callService!({ domain: "iotawatt", service: iotawattProfile.controlServices?.[0] || '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/iperf3/test.iperf3.node.ts b/test/iperf3/test.iperf3.node.ts new file mode 100644 index 0000000..f579fa2 --- /dev/null +++ b/test/iperf3/test.iperf3.node.ts @@ -0,0 +1,99 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIperf3Integration, Iperf3Client, Iperf3ConfigFlow, Iperf3Integration, Iperf3Mapper, createIperf3DiscoveryDescriptor, iperf3Profile, type IIperf3Snapshot, type TIperf3RawData } from '../../ts/integrations/iperf3/index.js'; + +const rawData: TIperf3RawData = { + device: { + id: 'iperf3-device-1', + name: "Iperf3 Device", + manufacturer: "Iperf3", + model: "Iperf3 local integration", + serialNumber: 'iperf3-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iperf3" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Iperf3 candidates and creates config flow output', async () => { + const descriptor = createIperf3DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iperf3-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iperf3-device-1', name: "Iperf3 Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("iperf3"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Iperf3ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('iperf3-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Iperf3 raw snapshots to runtime devices and entities', async () => { + const client = new Iperf3Client({ name: "Iperf3 Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = Iperf3Mapper.toSnapshotFromRaw({ name: "Iperf3 Runtime" }, rawData); + const devices = Iperf3Mapper.toDevices(mappedSnapshot); + const entities = Iperf3Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("iperf3"); + expect(devices[0].manufacturer).toEqual("Iperf3"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "iperf3" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('does not treat arbitrary Iperf3 host/path as a native live API', async () => { + const originalFetch = globalThis.fetch; + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response('{}'); + }) as typeof globalThis.fetch; + + try { + const snapshot = await new Iperf3Client({ host: '127.0.0.1', path: '/api', timeoutMs: 1000 }).getSnapshot(true); + + expect(fetched).toBeFalse(); + expect(snapshot.online).toBeFalse(); + expect(snapshot.error!).toContain('snapshots require'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Iperf3 runtime, HA alias, and unsupported control without executor', async () => { + const integration = new Iperf3Integration(); + const alias = new HomeAssistantIperf3Integration(); + expect(alias instanceof Iperf3Integration).toBeTrue(); + expect(alias.domain).toEqual("iperf3"); + expect(integration.status).toEqual("control-runtime"); + expect(iperf3Profile.metadata.configFlow).toEqual(false); + expect(iperf3Profile.metadata.requirements).toEqual([ + "iperf3==0.1.11", + ]); + expect((iperf3Profile.metadata.localApi as { status: string }).status).toContain('libiperf'); + expect((iperf3Profile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((entryArg) => entryArg.includes('libiperf.so'))).toBeTrue(); + + const runtime = await integration.setup({ name: "Iperf3 Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "iperf3", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "iperf3", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIperf3Snapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Iperf3 Device"); + + const command = await runtime.callService!({ domain: "iperf3", service: iperf3Profile.controlServices?.[0] || '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/iron_os/test.iron_os.node.ts b/test/iron_os/test.iron_os.node.ts new file mode 100644 index 0000000..9137efb --- /dev/null +++ b/test/iron_os/test.iron_os.node.ts @@ -0,0 +1,99 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIronOsIntegration, IronOsClient, IronOsConfigFlow, IronOsIntegration, IronOsMapper, createIronOsDiscoveryDescriptor, ironOsProfile, type IIronOsSnapshot, type TIronOsRawData } from '../../ts/integrations/iron_os/index.js'; + +const rawData: TIronOsRawData = { + device: { + id: 'iron_os-device-1', + name: "IronOS Device", + manufacturer: "IronOS", + model: "IronOS local integration", + serialNumber: 'iron_os-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "iron_os" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual IronOS candidates and creates config flow output', async () => { + const descriptor = createIronOsDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iron_os-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iron_os-device-1', name: "IronOS Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("iron_os"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IronOsConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('iron_os-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps IronOS raw snapshots to runtime devices and entities', async () => { + const client = new IronOsClient({ name: "IronOS Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IronOsMapper.toSnapshotFromRaw({ name: "IronOS Runtime" }, rawData); + const devices = IronOsMapper.toDevices(mappedSnapshot); + const entities = IronOsMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("iron_os"); + expect(devices[0].manufacturer).toEqual("IronOS"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "iron_os" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('does not treat arbitrary IronOS host/path as a native live API', async () => { + const originalFetch = globalThis.fetch; + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response('{}'); + }) as typeof globalThis.fetch; + + try { + const snapshot = await new IronOsClient({ host: '127.0.0.1', path: '/api', timeoutMs: 1000 }).getSnapshot(true); + + expect(fetched).toBeFalse(); + expect(snapshot.online).toBeFalse(); + expect(snapshot.error!).toContain('snapshots require'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes IronOS runtime, HA alias, and unsupported control without executor', async () => { + const integration = new IronOsIntegration(); + const alias = new HomeAssistantIronOsIntegration(); + expect(alias instanceof IronOsIntegration).toBeTrue(); + expect(alias.domain).toEqual("iron_os"); + expect(integration.status).toEqual("control-runtime"); + expect(ironOsProfile.metadata.configFlow).toEqual(true); + expect(ironOsProfile.metadata.requirements).toEqual([ + "pynecil==4.2.1", + ]); + expect((ironOsProfile.metadata.localApi as { status: string }).status).toContain('Bluetooth LE/GATT'); + expect((ironOsProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((entryArg) => entryArg.includes('bleak/habluetooth'))).toBeTrue(); + + const runtime = await integration.setup({ name: "IronOS Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "iron_os", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "iron_os", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIronOsSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("IronOS Device"); + + const command = await runtime.callService!({ domain: "iron_os", service: ironOsProfile.controlServices?.[0] || '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/iskra/test.iskra.node.ts b/test/iskra/test.iskra.node.ts new file mode 100644 index 0000000..6416d3e --- /dev/null +++ b/test/iskra/test.iskra.node.ts @@ -0,0 +1,149 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantIskraIntegration, IskraClient, IskraConfigFlow, IskraIntegration, IskraMapper, createIskraDiscoveryDescriptor, iskraProfile, type IIskraSnapshot, type TIskraRawData } from '../../ts/integrations/iskra/index.js'; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const rawData: TIskraRawData = { + device: { + id: 'iskra-device-1', + name: "iskra Device", + manufacturer: "iskra", + model: "iskra local integration", + serialNumber: 'iskra-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iskra" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual iskra candidates and creates config flow output', async () => { + const descriptor = createIskraDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iskra-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'iskra-device-1', name: "iskra Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("iskra"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IskraConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('iskra-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps iskra raw snapshots to runtime devices and entities', async () => { + const client = new IskraClient({ name: "iskra Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IskraMapper.toSnapshotFromRaw({ name: "iskra Runtime" }, rawData); + const devices = IskraMapper.toDevices(mappedSnapshot); + const entities = IskraMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("iskra"); + expect(devices[0].manufacturer).toEqual("iskra"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "iskra" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads iskra Smart Gateway snapshots over the local REST API', async () => { + const requests: Array<{ url?: string; cookie?: string }> = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + requests.push({ url: url.pathname, cookie: requestArg.headers.cookie }); + + if (url.pathname === '/api') { + json(responseArg, { device: { model_type: 'SG', serial_number: 'SG123', description: 'Main Gateway', location: 'Panel', sw_ver: '1.2' } }); + return; + } + if (url.pathname === '/api/devices') { + json(responseArg, { devices: [{ model: 'IE38', serial: 'IE38SER', description: 'Main meter', location: 'Panel', interface: 'RS485' }] }); + return; + } + if (url.pathname === '/api/measurement/0') { + json(responseArg, { + measurements: { + Phases: [ + { U: '230 V', I: { value: 5, unit: 'A' }, P: '100 W' }, + { U: '231 V', I: '4 A', P: '110 W' }, + { U: '232 V', I: '3 A', P: '120 W' }, + ], + Total: { P: '330 W', Q: '12 var', S: '350 VA' }, + Frequency: '50 Hz', + }, + }); + return; + } + if (url.pathname === '/api/counter/0') { + json(responseArg, { counters: { non_resettable: [{ value: 1234, unit: 'Wh', direction: 'import' }], resettable: [{ value: 56, unit: 'Wh', direction: 'export' }] } }); + return; + } + + json(responseArg, { error: 'not found' }, 404); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const config = { url: `http://127.0.0.1:${port}`, protocol: 'rest_api' as const, username: 'admin', password: 'iskra', timeoutMs: 1000 }; + const client = new IskraClient(config); + const snapshot = await client.getSnapshot(true); + const runtime = await new IskraIntegration().setup(config, {}); + const entities = await runtime.entities(); + const status = await runtime.callService!({ domain: 'iskra', service: 'status', target: {} }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('SG123'); + expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'total_active_power')?.state).toEqual(330); + expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'phase2_voltage')?.state).toEqual(231); + expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'non_resettable_counter_1')?.state).toEqual(1234); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'frequency')?.state).toEqual(50); + expect(status.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.url === '/api/measurement/0')).toBeTrue(); + expect(requests.every((requestArg) => requestArg.cookie?.startsWith('Authorization=Basic '))).toBeTrue(); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes iskra runtime, HA alias, and unsupported control without executor', async () => { + const integration = new IskraIntegration(); + const alias = new HomeAssistantIskraIntegration(); + expect(alias instanceof IskraIntegration).toBeTrue(); + expect(alias.domain).toEqual("iskra"); + expect(integration.status).toEqual("read-only-runtime"); + expect(iskraProfile.metadata.configFlow).toEqual(true); + expect(iskraProfile.metadata.requirements).toEqual([ + "pyiskra==0.1.27", + ]); + expect((iskraProfile.metadata.localApi as { implemented: string[] }).implemented.some((entryArg) => entryArg.includes('/api/measurement/{index}'))).toBeTrue(); + + const runtime = await integration.setup({ name: "iskra Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "iskra", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "iskra", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIskraSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("iskra Device"); + + const command = await runtime.callService!({ domain: "iskra", service: iskraProfile.controlServices?.[0] || '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/isy994/test.isy994.node.ts b/test/isy994/test.isy994.node.ts new file mode 100644 index 0000000..ff6f5b8 --- /dev/null +++ b/test/isy994/test.isy994.node.ts @@ -0,0 +1,131 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { HomeAssistantIsy994Integration, Isy994Client, Isy994ConfigFlow, Isy994Integration, Isy994Mapper, createIsy994DiscoveryDescriptor, isy994Profile, type IIsy994Snapshot, type TIsy994RawData } from '../../ts/integrations/isy994/index.js'; + +const rawData: TIsy994RawData = { + device: { + id: 'isy994-device-1', + name: "Universal Devices ISY/IoX Device", + manufacturer: "Universal Devices ISY/IoX", + model: "Universal Devices ISY/IoX local integration", + serialNumber: 'isy994-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "isy994" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const listen = async (serverArg: http.Server): Promise => await new Promise((resolveArg) => { + serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port)); +}); + +const close = async (serverArg: http.Server): Promise => await new Promise((resolveArg, rejectArg) => { + serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg()); +}); + +tap.test('matches manual Universal Devices ISY/IoX candidates and creates config flow output', async () => { + const descriptor = createIsy994DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'isy994-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'isy994-device-1', name: "Universal Devices ISY/IoX Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("isy994"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Isy994ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('isy994-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Universal Devices ISY/IoX raw snapshots to runtime devices and entities', async () => { + const client = new Isy994Client({ name: "Universal Devices ISY/IoX Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = Isy994Mapper.toSnapshotFromRaw({ name: "Universal Devices ISY/IoX Runtime" }, rawData); + const devices = Isy994Mapper.toDevices(mappedSnapshot); + const entities = Isy994Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("isy994"); + expect(devices[0].manufacturer).toEqual("Universal Devices ISY/IoX"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "isy994" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('polls Universal Devices ISY/IoX REST XML and sends a native node command', async () => { + const requests: Array<{ url?: string; authorization?: string }> = []; + const server = http.createServer((requestArg, responseArg) => { + requests.push({ url: requestArg.url, authorization: requestArg.headers.authorization }); + responseArg.setHeader('content-type', 'application/xml'); + if (requestArg.url?.startsWith('/rest/config')) { + responseArg.end('00:21:b9:aa:bb:ccTest ISYISY994i5.3.4truetrue'); + return; + } + if (requestArg.url?.startsWith('/rest/nodes/11%2022%2033%201/cmd/DON/255')) { + responseArg.end(''); + return; + } + if (requestArg.url?.startsWith('/rest/nodes')) { + responseArg.end('
11 22 33 1
Kitchen Light11.2.9.0true
'); + return; + } + if (requestArg.url?.startsWith('/rest/status')) { + responseArg.end(''); + return; + } + responseArg.statusCode = 404; + responseArg.end('not found'); + }); + const port = await listen(server); + try { + const client = new Isy994Client({ host: '127.0.0.1', port, username: 'admin', password: 'secret' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'isy994', service: 'send_raw_node_command', target: {}, data: { address: '11 22 33 1', command: 'DON', value: 255 } }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.name).toEqual('Test ISY'); + expect(snapshot.entities[0].name).toEqual('Kitchen Light'); + expect(snapshot.entities[0].platform).toEqual('light'); + expect(snapshot.entities[0].state).toEqual(255); + expect(command.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.url === '/rest/nodes/11%2022%2033%201/cmd/DON/255')).toBeTrue(); + expect(requests.every((requestArg) => requestArg.authorization === 'Basic YWRtaW46c2VjcmV0')).toBeTrue(); + } finally { + await close(server); + } +}); + +tap.test('exposes Universal Devices ISY/IoX runtime, HA alias, and unsupported control without executor', async () => { + const integration = new Isy994Integration(); + const alias = new HomeAssistantIsy994Integration(); + expect(alias instanceof Isy994Integration).toBeTrue(); + expect(alias.domain).toEqual("isy994"); + expect(integration.status).toEqual("control-runtime"); + expect(isy994Profile.metadata.configFlow).toEqual(true); + expect(isy994Profile.metadata.requirements).toEqual([ + "pyisy==3.5.1", + ]); + + const runtime = await integration.setup({ name: "Universal Devices ISY/IoX Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "isy994", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "isy994", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIsy994Snapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Universal Devices ISY/IoX Device"); + + const command = await runtime.callService!({ domain: "isy994", service: isy994Profile.controlServices?.[0] || '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/itunes/test.itunes.node.ts b/test/itunes/test.itunes.node.ts new file mode 100644 index 0000000..43129e4 --- /dev/null +++ b/test/itunes/test.itunes.node.ts @@ -0,0 +1,124 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { HomeAssistantItunesIntegration, ItunesClient, ItunesConfigFlow, ItunesIntegration, ItunesMapper, createItunesDiscoveryDescriptor, itunesProfile, type IItunesSnapshot, type TItunesRawData } from '../../ts/integrations/itunes/index.js'; + +const rawData: TItunesRawData = { + device: { + id: 'itunes-device-1', + name: "Apple iTunes Device", + manufacturer: "Apple iTunes", + model: "Apple iTunes local integration", + serialNumber: 'itunes-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "media_player", state: true, attributes: { domain: "itunes" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const listen = async (serverArg: http.Server): Promise => await new Promise((resolveArg) => { + serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port)); +}); + +const close = async (serverArg: http.Server): Promise => await new Promise((resolveArg, rejectArg) => { + serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg()); +}); + +tap.test('matches manual Apple iTunes candidates and creates config flow output', async () => { + const descriptor = createItunesDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'itunes-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'itunes-device-1', name: "Apple iTunes Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("itunes"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new ItunesConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('itunes-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Apple iTunes raw snapshots to runtime devices and entities', async () => { + const client = new ItunesClient({ name: "Apple iTunes Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = ItunesMapper.toSnapshotFromRaw({ name: "Apple iTunes Runtime" }, rawData); + const devices = ItunesMapper.toDevices(mappedSnapshot); + const entities = ItunesMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("itunes"); + expect(devices[0].manufacturer).toEqual("Apple iTunes"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "itunes" && entityArg.platform === "media_player")).toBeTrue(); +}); + +tap.test('polls itunes-api HTTP endpoints and sends native playback command', async () => { + const requests: Array<{ method?: string; url?: string }> = []; + const server = http.createServer((requestArg, responseArg) => { + requests.push({ method: requestArg.method, url: requestArg.url }); + responseArg.setHeader('content-type', 'application/json'); + if (requestArg.method === 'GET' && requestArg.url === '/now_playing') { + responseArg.end(JSON.stringify({ player_state: 'playing', volume: 40, muted: false, name: 'Test Track', album: 'Album', artist: 'Artist', playlist: 'Library', id: 'track-1', shuffle: 'off' })); + return; + } + if (requestArg.method === 'GET' && requestArg.url === '/airplay_devices') { + responseArg.end(JSON.stringify({ airplay_devices: [{ id: 'speaker-1', name: 'Kitchen', selected: true, sound_volume: 55, supports_audio: true, supports_video: false }] })); + return; + } + if (requestArg.method === 'PUT' && requestArg.url === '/pause') { + responseArg.end(JSON.stringify({ player_state: 'paused' })); + return; + } + responseArg.statusCode = 404; + responseArg.end(JSON.stringify({ error: 'not found' })); + }); + const port = await listen(server); + try { + const client = new ItunesClient({ host: '127.0.0.1', port }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'itunes', service: 'media_pause', target: {} }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'player' && entityArg.state === 'playing')).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'airplay_speaker_1' && entityArg.state === 'on')).toBeTrue(); + expect(command.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/now_playing')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/airplay_devices')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'PUT' && requestArg.url === '/pause')).toBeTrue(); + } finally { + await close(server); + } +}); + +tap.test('exposes Apple iTunes runtime, HA alias, and unsupported control without executor', async () => { + const integration = new ItunesIntegration(); + const alias = new HomeAssistantItunesIntegration(); + expect(alias instanceof ItunesIntegration).toBeTrue(); + expect(alias.domain).toEqual("itunes"); + expect(integration.status).toEqual("control-runtime"); + expect(itunesProfile.metadata.configFlow).toEqual(false); + expect(itunesProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: "Apple iTunes Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "itunes", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "itunes", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IItunesSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Apple iTunes Device"); + + const command = await runtime.callService!({ domain: "itunes", service: itunesProfile.controlServices?.[0] || '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/izone/test.izone.node.ts b/test/izone/test.izone.node.ts new file mode 100644 index 0000000..89ebafb --- /dev/null +++ b/test/izone/test.izone.node.ts @@ -0,0 +1,142 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { HomeAssistantIzoneIntegration, IzoneClient, IzoneConfigFlow, IzoneIntegration, IzoneMapper, createIzoneDiscoveryDescriptor, izoneProfile, type IIzoneSnapshot, type TIzoneRawData } from '../../ts/integrations/izone/index.js'; + +const rawData: TIzoneRawData = { + device: { + id: 'izone-device-1', + name: "iZone Device", + manufacturer: "iZone", + model: "iZone local integration", + serialNumber: 'izone-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "climate", state: true, attributes: { domain: "izone" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const listen = async (serverArg: http.Server): Promise => await new Promise((resolveArg) => { + serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port)); +}); + +const close = async (serverArg: http.Server): Promise => await new Promise((resolveArg, rejectArg) => { + serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg()); +}); + +const readBody = async (requestArg: http.IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +tap.test('matches manual iZone candidates and creates config flow output', async () => { + const descriptor = createIzoneDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'izone-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'izone-device-1', name: "iZone Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("izone"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new IzoneConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('izone-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps iZone raw snapshots to runtime devices and entities', async () => { + const client = new IzoneClient({ name: "iZone Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = IzoneMapper.toSnapshotFromRaw({ name: "iZone Runtime" }, rawData); + const devices = IzoneMapper.toDevices(mappedSnapshot); + const entities = IzoneMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("izone"); + expect(devices[0].manufacturer).toEqual("iZone"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "izone" && entityArg.platform === "climate")).toBeTrue(); +}); + +tap.test('polls iZone HTTP resources and sends native raw TCP HTTP command', async () => { + const requests: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = http.createServer(async (requestArg, responseArg) => { + responseArg.setHeader('content-type', 'application/json'); + if (requestArg.method === 'GET' && requestArg.url === '/SystemSettings') { + requests.push({ method: requestArg.method, url: requestArg.url }); + responseArg.end(JSON.stringify({ AirStreamDeviceUId: '000013170', SysOn: 'on', SysMode: 'cool', SysFan: 'medium', FanAuto: '3-speed', NoOfZones: 2, Supply: 12.3, Setpoint: 22, Temp: 23.5, EcoLock: 'false', EcoMin: 15, EcoMax: 30, RAS: 'master', CtrlZone: 1, NoOfConst: 0, SysType: '310', FreeAir: 'off' })); + return; + } + if (requestArg.method === 'GET' && requestArg.url === '/Zones1_4') { + requests.push({ method: requestArg.method, url: requestArg.url }); + responseArg.end(JSON.stringify([ + { Index: 0, Name: 'Living', Type: 'auto', Mode: 'auto', SetPoint: 22, Temp: 23, MaxAir: 80, MinAir: 20 }, + { Index: 1, Name: 'Bed', Type: 'opcl', Mode: 'open', SetPoint: 0, Temp: 0, MaxAir: 100, MinAir: 10 }, + ])); + return; + } + if (requestArg.method === 'POST' && requestArg.url === '/AirMinCommand') { + const body = JSON.parse(await readBody(requestArg)) as unknown; + requests.push({ method: requestArg.method, url: requestArg.url, body }); + responseArg.end('{"ok":true}{OK}'); + return; + } + requests.push({ method: requestArg.method, url: requestArg.url }); + responseArg.statusCode = 404; + responseArg.end(JSON.stringify({ error: 'not found' })); + }); + const port = await listen(server); + try { + const client = new IzoneClient({ host: '127.0.0.1', port }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'izone', service: 'airflow_min', target: {}, data: { zoneNo: 1, airflow: 25 } }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.id).toEqual('000013170'); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'controller' && entityArg.state === 'cool')).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'zone_1' && entityArg.state === 'heat_cool')).toBeTrue(); + expect(command.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/SystemSettings')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/Zones1_4')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'POST' && requestArg.url === '/AirMinCommand' && JSON.stringify(requestArg.body) === JSON.stringify({ AirMinCommand: { ZoneNo: '1', Command: '25' } }))).toBeTrue(); + } finally { + await close(server); + } +}); + +tap.test('exposes iZone runtime, HA alias, and unsupported control without executor', async () => { + const integration = new IzoneIntegration(); + const alias = new HomeAssistantIzoneIntegration(); + expect(alias instanceof IzoneIntegration).toBeTrue(); + expect(alias.domain).toEqual("izone"); + expect(integration.status).toEqual("control-runtime"); + expect(izoneProfile.metadata.configFlow).toEqual(true); + expect(izoneProfile.metadata.requirements).toEqual([ + "python-izone==1.2.9", + ]); + + const runtime = await integration.setup({ name: "iZone Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "izone", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "izone", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IIzoneSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("iZone Device"); + + const command = await runtime.callService!({ domain: "izone", service: izoneProfile.controlServices?.[0] || '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/jvc_projector/test.jvc_projector.node.ts b/test/jvc_projector/test.jvc_projector.node.ts new file mode 100644 index 0000000..03d2d39 --- /dev/null +++ b/test/jvc_projector/test.jvc_projector.node.ts @@ -0,0 +1,158 @@ +import { createServer } from 'node:net'; +import type { AddressInfo, Socket } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantJvcProjectorIntegration, JvcProjectorClient, JvcProjectorConfigFlow, JvcProjectorIntegration, JvcProjectorMapper, createJvcProjectorDiscoveryDescriptor, jvcProjectorProfile, type IJvcProjectorSnapshot, type TJvcProjectorRawData } from '../../ts/integrations/jvc_projector/index.js'; + +const rawData: TJvcProjectorRawData = { + device: { + id: 'jvc_projector-device-1', + name: "JVC Projector Device", + manufacturer: "JVC Projector", + model: "JVC Projector local integration", + serialNumber: 'jvc_projector-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "jvc_projector" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const startJvcProjectorServer = async () => { + const received: string[] = []; + const pjReq = Buffer.from('PJREQ', 'latin1'); + const pjAck = Buffer.from('PJACK', 'latin1'); + const headAck = Buffer.from([0x06, 0x89, 0x01]); + const headRes = Buffer.from([0x40, 0x89, 0x01]); + const newline = Buffer.from('\n', 'latin1'); + const responses: Record = { + MD: 'B5A3', + LSMA: 'AA BB CC DD EE FF', + IFSV: '1.23', + PW: '1', + SC: '1', + IP: '6', + IFLT: '000A', + }; + const knownCodes = Object.keys(responses).concat('RC').sort((leftArg, rightArg) => rightArg.length - leftArg.length); + const server = createServer((socketArg: Socket) => { + let authenticated = false; + let buffer = Buffer.alloc(0); + socketArg.write(Buffer.from('PJ_OK', 'latin1')); + + const handleFrame = (frameArg: Buffer) => { + const isOperation = frameArg[0] === 0x21; + const body = frameArg.subarray(3, frameArg.length - 1).toString('latin1'); + const code = knownCodes.find((codeArg) => body.startsWith(codeArg)); + if (!code) { + return; + } + received.push(`${isOperation ? 'op' : 'ref'}:${body}`); + const prefix = Buffer.from(code.slice(0, 2), 'latin1'); + socketArg.write(Buffer.concat([headAck, prefix, newline])); + if (!isOperation) { + socketArg.write(Buffer.concat([headRes, prefix, Buffer.from(responses[code], 'latin1'), newline])); + } + }; + + socketArg.on('data', (chunkArg: Buffer) => { + buffer = Buffer.concat([buffer, chunkArg]); + if (!authenticated && buffer.length >= pjReq.length && buffer.subarray(0, pjReq.length).equals(pjReq)) { + received.push('PJREQ'); + buffer = buffer.subarray(pjReq.length); + authenticated = true; + socketArg.write(pjAck); + } + let index = buffer.indexOf(0x0a); + while (index >= 0) { + const frame = buffer.subarray(0, index + 1); + buffer = buffer.subarray(index + 1); + handleFrame(frame); + index = buffer.indexOf(0x0a); + } + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { server, port: (server.address() as AddressInfo).port, received }; +}; + +tap.test('matches manual JVC Projector candidates and creates config flow output', async () => { + const descriptor = createJvcProjectorDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'jvc_projector-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'jvc_projector-device-1', name: "JVC Projector Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("jvc_projector"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new JvcProjectorConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('jvc_projector-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps JVC Projector raw snapshots to runtime devices and entities', async () => { + const client = new JvcProjectorClient({ name: "JVC Projector Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = JvcProjectorMapper.toSnapshotFromRaw({ name: "JVC Projector Runtime" }, rawData); + const devices = JvcProjectorMapper.toDevices(mappedSnapshot); + const entities = JvcProjectorMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("jvc_projector"); + expect(devices[0].manufacturer).toEqual("JVC Projector"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "jvc_projector" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('polls and controls JVC Projector through the pyjvcprojector TCP protocol', async () => { + const { server, port, received } = await startJvcProjectorServer(); + try { + const client = new JvcProjectorClient({ host: '127.0.0.1', port, name: 'Theater Projector' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'remote', service: 'send_command', target: {}, data: { command: ['menu'] } }); + + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.device.model).toEqual('B5A3'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'input')?.state).toEqual('hdmi1'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'light_time')?.state).toEqual(10); + expect(command.success).toBeTrue(); + expect(received.some((entryArg) => entryArg === 'ref:MD')).toBeTrue(); + expect(received.some((entryArg) => entryArg === 'op:RC732E')).toBeTrue(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +tap.test('exposes JVC Projector runtime, HA alias, and unsupported control without executor', async () => { + const integration = new JvcProjectorIntegration(); + const alias = new HomeAssistantJvcProjectorIntegration(); + expect(alias instanceof JvcProjectorIntegration).toBeTrue(); + expect(alias.domain).toEqual("jvc_projector"); + expect(integration.status).toEqual("control-runtime"); + expect(jvcProjectorProfile.metadata.configFlow).toEqual(true); + expect(jvcProjectorProfile.metadata.requirements).toEqual([ + "pyjvcprojector==2.0.6", + ]); + + const runtime = await integration.setup({ name: "JVC Projector Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "jvc_projector", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "jvc_projector", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IJvcProjectorSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("JVC Projector Device"); + + const command = await runtime.callService!({ domain: "jvc_projector", service: jvcProjectorProfile.controlServices?.[0] || '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/kaleidescape/test.kaleidescape.node.ts b/test/kaleidescape/test.kaleidescape.node.ts new file mode 100644 index 0000000..4bbcebb --- /dev/null +++ b/test/kaleidescape/test.kaleidescape.node.ts @@ -0,0 +1,146 @@ +import { createServer } from 'node:net'; +import type { AddressInfo, Socket } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantKaleidescapeIntegration, KaleidescapeClient, KaleidescapeConfigFlow, KaleidescapeIntegration, KaleidescapeMapper, createKaleidescapeDiscoveryDescriptor, kaleidescapeProfile, type IKaleidescapeSnapshot, type TKaleidescapeRawData } from '../../ts/integrations/kaleidescape/index.js'; + +const rawData: TKaleidescapeRawData = { + device: { + id: 'kaleidescape-device-1', + name: "Kaleidescape Device", + manufacturer: "Kaleidescape", + model: "Kaleidescape local integration", + serialNumber: 'kaleidescape-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "kaleidescape" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const startKaleidescapeServer = async () => { + const received: string[] = []; + const responses: Record = { + GET_DEVICE_INFO: ['unused', '#000000123456', '01', '192.168.001.050'], + GET_SYSTEM_VERSION: ['13', '10.0.0'], + GET_DEVICE_TYPE_NAME: ['Strato C'], + GET_NUM_ZONES: ['1', '1'], + GET_DEVICE_POWER_STATE: ['1', '1'], + GET_SYSTEM_READINESS_STATE: ['0'], + GET_FRIENDLY_NAME: ['Theater'], + GET_UI_STATE: ['7', '0', '0', '0'], + GET_HIGHLIGHTED_SELECTION: ['movie-handle'], + GET_PLAY_STATUS: ['2', '1', '1', '7200', '120', '1', '600', '120'], + GET_MOVIE_LOCATION: ['3'], + GET_VIDEO_MODE: ['0', '0', '27'], + GET_VIDEO_COLOR: ['2', '4', '30', '3'], + GET_SCREEN_MASK: ['5', '0', '0', '5', '0', '0'], + GET_CINEMASCAPE_MODE: ['1'], + }; + const server = createServer((socketArg: Socket) => { + let buffer = ''; + socketArg.on('data', (chunkArg: Buffer) => { + buffer += chunkArg.toString('latin1'); + let index = buffer.indexOf('\n'); + while (index >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + const match = line.match(/^01\/([0-9])\/([^:]+):(.*)$/); + if (match) { + const seq = match[1]; + const requestName = match[2]; + received.push(requestName); + const responseName = requestName.startsWith('GET_') ? requestName.slice(4) : ''; + const fields = responses[requestName] || []; + const body = responseName ? `${responseName}:${fields.join(':')}:` : ''; + socketArg.write(`01/${seq}/000:${body}/0\n`, 'latin1'); + } + index = buffer.indexOf('\n'); + } + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { server, port: (server.address() as AddressInfo).port, received }; +}; + +tap.test('matches manual Kaleidescape candidates and creates config flow output', async () => { + const descriptor = createKaleidescapeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kaleidescape-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kaleidescape-device-1', name: "Kaleidescape Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kaleidescape"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KaleidescapeConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kaleidescape-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kaleidescape raw snapshots to runtime devices and entities', async () => { + const client = new KaleidescapeClient({ name: "Kaleidescape Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KaleidescapeMapper.toSnapshotFromRaw({ name: "Kaleidescape Runtime" }, rawData); + const devices = KaleidescapeMapper.toDevices(mappedSnapshot); + const entities = KaleidescapeMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kaleidescape"); + expect(devices[0].manufacturer).toEqual("Kaleidescape"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kaleidescape" && entityArg.platform === "button")).toBeTrue(); +}); + +tap.test('polls and controls Kaleidescape through the System Control Protocol TCP API', async () => { + const { server, port, received } = await startKaleidescapeServer(); + try { + const client = new KaleidescapeClient({ host: '127.0.0.1', port, name: 'Theater' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'media_player', service: 'media_pause', target: {} }); + + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.device.model).toEqual('Strato C'); + expect(snapshot.device.serialNumber).toEqual('000000123456'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'media_player')?.state).toEqual('playing'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'video_mode')?.state).toEqual('3840x2160p23976_16:9'); + expect(command.success).toBeTrue(); + expect(received.includes('GET_DEVICE_INFO')).toBeTrue(); + expect(received.includes('PAUSE')).toBeTrue(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +tap.test('exposes Kaleidescape runtime, HA alias, and unsupported control without executor', async () => { + const integration = new KaleidescapeIntegration(); + const alias = new HomeAssistantKaleidescapeIntegration(); + expect(alias instanceof KaleidescapeIntegration).toBeTrue(); + expect(alias.domain).toEqual("kaleidescape"); + expect(integration.status).toEqual("control-runtime"); + expect(kaleidescapeProfile.metadata.configFlow).toEqual(true); + expect(kaleidescapeProfile.metadata.requirements).toEqual([ + "pykaleidescape==1.1.5", + ]); + + const runtime = await integration.setup({ name: "Kaleidescape Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kaleidescape", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kaleidescape", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKaleidescapeSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kaleidescape Device"); + + const command = await runtime.callService!({ domain: "kaleidescape", service: kaleidescapeProfile.controlServices?.[0] || '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/kankun/test.kankun.node.ts b/test/kankun/test.kankun.node.ts new file mode 100644 index 0000000..81f6dc2 --- /dev/null +++ b/test/kankun/test.kankun.node.ts @@ -0,0 +1,107 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantKankunIntegration, KankunClient, KankunConfigFlow, KankunIntegration, KankunMapper, createKankunDiscoveryDescriptor, kankunProfile, type IKankunSnapshot, type TKankunRawData } from '../../ts/integrations/kankun/index.js'; + +const rawData: TKankunRawData = { + device: { + id: 'kankun-device-1', + name: "Kankun Device", + manufacturer: "Kankun", + model: "Kankun local integration", + serialNumber: 'kankun-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "kankun" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kankun candidates and creates config flow output', async () => { + const descriptor = createKankunDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kankun-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kankun-device-1', name: "Kankun Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kankun"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KankunConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kankun-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kankun raw snapshots to runtime devices and entities', async () => { + const client = new KankunClient({ name: "Kankun Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KankunMapper.toSnapshotFromRaw({ name: "Kankun Runtime" }, rawData); + const devices = KankunMapper.toDevices(mappedSnapshot); + const entities = KankunMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kankun"); + expect(devices[0].manufacturer).toEqual("Kankun"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kankun" && entityArg.platform === "switch")).toBeTrue(); +}); + +tap.test('polls and controls Kankun switches through the documented json.cgi HTTP API', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => { + const url = String(inputArg); + calls.push({ url, init: initArg }); + if (url.endsWith('/cgi-bin/json.cgi?get=state')) { + return new Response(JSON.stringify({ state: 'on' }), { status: 200 }); + } + if (url.endsWith('/cgi-bin/json.cgi?set=off')) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false }), { status: 404 }); + }) as typeof fetch; + + try { + const client = new KankunClient({ host: 'plug.local', username: 'admin', password: 'secret', name: 'Bedroom Plug' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'switch', service: 'turn_off', target: {} }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'switch')?.state).toBeTrue(); + expect(command.success).toBeTrue(); + expect(calls[0].url).toEqual('http://plug.local/cgi-bin/json.cgi?get=state'); + expect(String((calls[0].init?.headers as Record).authorization)).toContain('Basic '); + expect(calls[1].url).toEqual('http://plug.local/cgi-bin/json.cgi?set=off'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Kankun runtime, HA alias, and unsupported control without executor', async () => { + const integration = new KankunIntegration(); + const alias = new HomeAssistantKankunIntegration(); + expect(alias instanceof KankunIntegration).toBeTrue(); + expect(alias.domain).toEqual("kankun"); + expect(integration.status).toEqual("control-runtime"); + expect(kankunProfile.metadata.configFlow).toEqual(false); + expect(kankunProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: "Kankun Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kankun", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kankun", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKankunSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kankun Device"); + + const command = await runtime.callService!({ domain: "kankun", service: kankunProfile.controlServices?.[0] || '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/keba/test.keba.node.ts b/test/keba/test.keba.node.ts new file mode 100644 index 0000000..e6c3529 --- /dev/null +++ b/test/keba/test.keba.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantKebaIntegration, KebaClient, KebaConfigFlow, KebaIntegration, KebaMapper, createKebaDiscoveryDescriptor, kebaProfile, type IKebaSnapshot, type TKebaRawData } from '../../ts/integrations/keba/index.js'; + +const rawData: TKebaRawData = { + device: { + id: 'keba-device-1', + name: "Keba Charging Station Device", + manufacturer: "Keba Charging Station", + model: "Keba Charging Station local integration", + serialNumber: 'keba-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "keba" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Keba Charging Station candidates and creates config flow output', async () => { + const descriptor = createKebaDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keba-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'keba-device-1', name: "Keba Charging Station Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("keba"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KebaConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('keba-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Keba Charging Station raw snapshots to runtime devices and entities', async () => { + const client = new KebaClient({ name: "Keba Charging Station Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KebaMapper.toSnapshotFromRaw({ name: "Keba Charging Station Runtime" }, rawData); + const devices = KebaMapper.toDevices(mappedSnapshot); + const entities = KebaMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("keba"); + expect(devices[0].manufacturer).toEqual("Keba Charging Station"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "keba" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('exposes Keba Charging Station runtime, HA alias, and unsupported control without executor', async () => { + const integration = new KebaIntegration(); + const alias = new HomeAssistantKebaIntegration(); + expect(alias instanceof KebaIntegration).toBeTrue(); + expect(alias.domain).toEqual("keba"); + expect(integration.status).toEqual("control-runtime"); + expect(kebaProfile.metadata.configFlow).toEqual(false); + expect(kebaProfile.metadata.requirements).toEqual([ + "keba-kecontact==1.3.0", + ]); + const localApi = kebaProfile.metadata.localApi as { status: string; explicitUnsupported: string[] }; + expect(localApi.status).toContain('KEBA KeContact UDP'); + expect(localApi.status).toContain('asyncio-dgram'); + expect(localApi.explicitUnsupported.some((entryArg) => entryArg.includes('no HTTP/TCP/TLS/file protocol'))).toBeTrue(); + + const runtime = await integration.setup({ name: "Keba Charging Station Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "keba", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "keba", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKebaSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Keba Charging Station Device"); + + const command = await runtime.callService!({ domain: "keba", service: kebaProfile.controlServices?.[0] || '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/keenetic_ndms2/test.keenetic_ndms2.node.ts b/test/keenetic_ndms2/test.keenetic_ndms2.node.ts new file mode 100644 index 0000000..a62dd5f --- /dev/null +++ b/test/keenetic_ndms2/test.keenetic_ndms2.node.ts @@ -0,0 +1,158 @@ +import { createServer } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantKeeneticNdms2Integration, KeeneticNdms2Client, KeeneticNdms2ConfigFlow, KeeneticNdms2Integration, KeeneticNdms2Mapper, createKeeneticNdms2DiscoveryDescriptor, keeneticNdms2Profile, type IKeeneticNdms2Snapshot, type TKeeneticNdms2RawData } from '../../ts/integrations/keenetic_ndms2/index.js'; + +const rawData: TKeeneticNdms2RawData = { + device: { + id: 'keenetic_ndms2-device-1', + name: "Keenetic NDMS2 Router Device", + manufacturer: "Keenetic NDMS2 Router", + model: "Keenetic NDMS2 Router local integration", + serialNumber: 'keenetic_ndms2-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "keenetic_ndms2" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Keenetic NDMS2 Router candidates and creates config flow output', async () => { + const descriptor = createKeeneticNdms2DiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keenetic_ndms2-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'keenetic_ndms2-device-1', name: "Keenetic NDMS2 Router Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("keenetic_ndms2"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KeeneticNdms2ConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('keenetic_ndms2-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Keenetic NDMS2 Router raw snapshots to runtime devices and entities', async () => { + const client = new KeeneticNdms2Client({ name: "Keenetic NDMS2 Router Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KeeneticNdms2Mapper.toSnapshotFromRaw({ name: "Keenetic NDMS2 Router Runtime" }, rawData); + const devices = KeeneticNdms2Mapper.toDevices(mappedSnapshot); + const entities = KeeneticNdms2Mapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("keenetic_ndms2"); + expect(devices[0].manufacturer).toEqual("Keenetic NDMS2 Router"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "keenetic_ndms2" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads Keenetic NDMS2 router and client presence through native local Telnet', async () => { + const commands: string[] = []; + const server = createServer((socketArg) => { + let stage: 'login' | 'password' | 'command' = 'login'; + let buffer = ''; + socketArg.setEncoding('utf8'); + socketArg.write('Login: '); + socketArg.on('data', (chunkArg) => { + buffer += String(chunkArg).replace(/\r/g, ''); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const value = line.trim(); + if (stage === 'login') { + stage = 'password'; + socketArg.write('Password: '); + } else if (stage === 'password') { + stage = 'command'; + socketArg.write('\r\n(router)>'); + } else if (value !== 'exit') { + commands.push(value); + socketArg.write(keeneticResponse(value)); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const config = { host: '127.0.0.1', port, username: 'admin', password: 'secret', timeoutMs: 1000, interfaces: ['Home'], name: 'Office Keenetic' }; + const client = new KeeneticNdms2Client(config); + const snapshot = await client.getSnapshot(true); + const raw = snapshot.rawData as { routerInfo: { model: string; name: string }; devices: Array<{ mac: string; ip: string; interface: string }> }; + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + expect(raw.routerInfo.model).toEqual('Keenetic Giga'); + expect(raw.routerInfo.name).toEqual('Office Router'); + expect(raw.devices.length).toEqual(1); + expect(raw.devices[0].mac).toEqual('AA:BB:CC:DD:EE:01'); + expect(raw.devices[0].interface).toEqual('Home'); + expect(commands.includes('show version')).toBeTrue(); + expect(commands.includes('show ip arp')).toBeTrue(); + + const runtime = await new KeeneticNdms2Integration().setup(config, {}); + const status = await runtime.callService!({ domain: 'keenetic_ndms2', service: 'status', target: {} }); + const entities = await runtime.entities(); + expect(status.success).toBeTrue(); + expect((status.data as IKeeneticNdms2Snapshot).source).toEqual('tcp'); + expect(entities.find((entityArg) => entityArg.id.includes('client_aa_bb_cc_dd_ee_01'))?.attributes?.ipAddress).toEqual('192.168.1.10'); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Keenetic NDMS2 Router runtime, HA alias, and unsupported control without executor', async () => { + const integration = new KeeneticNdms2Integration(); + const alias = new HomeAssistantKeeneticNdms2Integration(); + expect(alias instanceof KeeneticNdms2Integration).toBeTrue(); + expect(alias.domain).toEqual("keenetic_ndms2"); + expect(integration.status).toEqual("read-only-runtime"); + expect(keeneticNdms2Profile.metadata.configFlow).toEqual(true); + expect(keeneticNdms2Profile.metadata.requirements).toEqual([ + "ndms2-client==0.1.2", + ]); + + const runtime = await integration.setup({ name: "Keenetic NDMS2 Router Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "keenetic_ndms2", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "keenetic_ndms2", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKeeneticNdms2Snapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Keenetic NDMS2 Router Device"); + + const command = await runtime.callService!({ domain: "keenetic_ndms2", service: keeneticNdms2Profile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +const keeneticResponse = (commandArg: string): string => { + const responses: Record = { + 'show version': [ + ' description: Office Router', + ' title: 4.1.0', + ' model: Keenetic Giga', + ' hw_version: A1', + ' manufacturer: Keenetic Ltd.', + ' vendor: Keenetic', + ' region: EU', + ], + 'show ip hotspot': [], + 'show ip arp': [ + 'Phone One 192.168.1.10 aa:bb:cc:dd:ee:01 Home ', + 'Guest Device 192.168.2.20 aa:bb:cc:dd:ee:02 Guest ', + ], + 'show associations': [], + }; + return `\r\n${commandArg}\r\n${(responses[commandArg] || []).join('\r\n')}\r\n(router)>`; +}; + +export default tap.start(); diff --git a/test/kef/test.kef.node.ts b/test/kef/test.kef.node.ts new file mode 100644 index 0000000..c435cbc --- /dev/null +++ b/test/kef/test.kef.node.ts @@ -0,0 +1,140 @@ +import { createServer } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantKefIntegration, KefClient, KefConfigFlow, KefIntegration, KefMapper, createKefDiscoveryDescriptor, kefProfile, type IKefSnapshot, type TKefRawData } from '../../ts/integrations/kef/index.js'; + +const rawData: TKefRawData = { + device: { + id: 'kef-device-1', + name: "KEF Device", + manufacturer: "KEF", + model: "KEF local integration", + serialNumber: 'kef-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "media_player", state: true, attributes: { domain: "kef" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual KEF candidates and creates config flow output', async () => { + const descriptor = createKefDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kef-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kef-device-1', name: "KEF Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kef"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KefConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kef-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps KEF raw snapshots to runtime devices and entities', async () => { + const client = new KefClient({ name: "KEF Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KefMapper.toSnapshotFromRaw({ name: "KEF Runtime" }, rawData); + const devices = KefMapper.toDevices(mappedSnapshot); + const entities = KefMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kef"); + expect(devices[0].manufacturer).toEqual("KEF"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kef" && entityArg.platform === "media_player")).toBeTrue(); +}); + +tap.test('reads and controls KEF speakers through the native local TCP protocol', async () => { + const commands: number[][] = []; + let volumeRaw = 25; + let sourceRaw = 2; + const server = createServer((socketArg) => { + let buffer = Buffer.alloc(0); + socketArg.on('data', (chunkArg) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)]); + while (buffer.length >= 3) { + const length = buffer[0] === 0x53 ? 4 : 3; + if (buffer.length < length) { + break; + } + const command = buffer.subarray(0, length); + buffer = buffer.subarray(length); + commands.push([...command]); + if (command[0] === 0x47) { + const value = command[1] === 0x25 ? volumeRaw : sourceRaw; + socketArg.write(Buffer.from([0x52, command[1], value, 0xff])); + } else if (command[0] === 0x53) { + if (command[1] === 0x25) { + volumeRaw = command[3]; + } + if (command[1] === 0x30) { + sourceRaw = command[3]; + } + socketArg.write(Buffer.from([0x52, 0x11, 0xff])); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const client = new KefClient({ host: '127.0.0.1', port, timeoutMs: 1000, name: 'Office KEF', speakerType: 'LSX' }); + const snapshot = await client.getSnapshot(true); + await client.execute({ domain: 'media_player', service: 'volume_up', target: {} }); + await client.execute({ domain: 'media_player', service: 'select_source', target: {}, data: { source: 'Bluetooth' } }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + expect((snapshot.rawData as { state: { source: string }; volume: { level: number } }).state.source).toEqual('Wifi'); + expect((snapshot.rawData as { state: { source: string }; volume: { level: number } }).volume.level).toEqual(0.25); + expect(commands.some((commandArg) => commandArg.join(',') === '83,37,129,30')).toBeTrue(); + expect(commands.some((commandArg) => commandArg.join(',') === '83,48,129,41')).toBeTrue(); + + const runtime = await new KefIntegration().setup({ host: '127.0.0.1', port, timeoutMs: 1000, name: 'Office KEF', speakerType: 'LSX' }, {}); + const status = await runtime.callService!({ domain: 'kef', service: 'status', target: {} }); + const entities = await runtime.entities(); + expect(status.success).toBeTrue(); + expect((status.data as IKefSnapshot).source).toEqual('tcp'); + expect(entities.find((entityArg) => entityArg.platform === 'media_player')?.attributes?.source).toEqual('Bluetooth'); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes KEF runtime, HA alias, and unsupported control without executor', async () => { + const integration = new KefIntegration(); + const alias = new HomeAssistantKefIntegration(); + expect(alias instanceof KefIntegration).toBeTrue(); + expect(alias.domain).toEqual("kef"); + expect(integration.status).toEqual("control-runtime"); + expect(kefProfile.metadata.configFlow).toEqual(false); + expect(kefProfile.metadata.requirements).toEqual([ + "aiokef==0.2.16", + "getmac==0.9.5", + ]); + + const runtime = await integration.setup({ name: "KEF Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kef", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kef", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKefSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("KEF Device"); + + const command = await runtime.callService!({ domain: "kef", service: kefProfile.controlServices?.[0] || '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/kegtron/test.kegtron.node.ts b/test/kegtron/test.kegtron.node.ts new file mode 100644 index 0000000..70fd2b2 --- /dev/null +++ b/test/kegtron/test.kegtron.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KegtronClient, KegtronConfigFlow, KegtronIntegration, KegtronMapper, createKegtronDiscoveryDescriptor, kegtronProfile, type IKegtronSnapshot, type TKegtronRawData } from '../../ts/integrations/kegtron/index.js'; + +const rawData: TKegtronRawData = { + device: { + id: 'kegtron-device-1', + name: "Kegtron Device", + manufacturer: "Kegtron", + model: "Kegtron local integration", + serialNumber: 'kegtron-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "kegtron" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kegtron candidates and creates config flow output', async () => { + const descriptor = createKegtronDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kegtron-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kegtron-device-1', name: "Kegtron Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kegtron"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KegtronConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kegtron-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kegtron raw snapshots to runtime devices and entities', async () => { + const client = new KegtronClient({ name: "Kegtron Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KegtronMapper.toSnapshotFromRaw({ name: "Kegtron Runtime" }, rawData); + const devices = KegtronMapper.toDevices(mappedSnapshot); + const entities = KegtronMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kegtron"); + expect(devices[0].manufacturer).toEqual("Kegtron"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kegtron" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes Kegtron runtime and unsupported control without executor', async () => { + const integration = new KegtronIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(kegtronProfile.metadata.configFlow).toEqual(true); + expect(kegtronProfile.metadata.requirements).toEqual([ + "kegtron-ble==1.0.2", + ]); + expect((kegtronProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((itemArg) => itemArg.includes('no BLE scanner stack'))).toBeTrue(); + + const runtime = await integration.setup({ name: "Kegtron Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kegtron", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kegtron", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKegtronSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kegtron Device"); + + const command = await runtime.callService!({ domain: "kegtron", service: kegtronProfile.controlServices?.[0] || '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/keyboard_remote/test.keyboard_remote.node.ts b/test/keyboard_remote/test.keyboard_remote.node.ts new file mode 100644 index 0000000..2d0fed5 --- /dev/null +++ b/test/keyboard_remote/test.keyboard_remote.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KeyboardRemoteClient, KeyboardRemoteConfigFlow, KeyboardRemoteIntegration, KeyboardRemoteMapper, createKeyboardRemoteDiscoveryDescriptor, keyboardRemoteProfile, type IKeyboardRemoteSnapshot, type TKeyboardRemoteRawData } from '../../ts/integrations/keyboard_remote/index.js'; + +const rawData: TKeyboardRemoteRawData = { + device: { + id: 'keyboard_remote-device-1', + name: "Keyboard Remote Device", + manufacturer: "Keyboard Remote", + model: "Keyboard Remote local integration", + serialNumber: 'keyboard_remote-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "keyboard_remote" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Keyboard Remote candidates and creates config flow output', async () => { + const descriptor = createKeyboardRemoteDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keyboard_remote-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'keyboard_remote-device-1', name: "Keyboard Remote Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("keyboard_remote"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KeyboardRemoteConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('keyboard_remote-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Keyboard Remote raw snapshots to runtime devices and entities', async () => { + const client = new KeyboardRemoteClient({ name: "Keyboard Remote Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KeyboardRemoteMapper.toSnapshotFromRaw({ name: "Keyboard Remote Runtime" }, rawData); + const devices = KeyboardRemoteMapper.toDevices(mappedSnapshot); + const entities = KeyboardRemoteMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("keyboard_remote"); + expect(devices[0].manufacturer).toEqual("Keyboard Remote"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "keyboard_remote" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes Keyboard Remote runtime and unsupported control without executor', async () => { + const integration = new KeyboardRemoteIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(keyboardRemoteProfile.metadata.configFlow).toEqual(false); + expect(keyboardRemoteProfile.metadata.requirements).toEqual([ + "evdev==1.9.3", + "asyncinotify==4.4.4", + ]); + expect((keyboardRemoteProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((itemArg) => itemArg.includes('no evdev/inotify/ioctl stack'))).toBeTrue(); + + const runtime = await integration.setup({ name: "Keyboard Remote Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "keyboard_remote", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "keyboard_remote", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKeyboardRemoteSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Keyboard Remote Device"); + + const command = await runtime.callService!({ domain: "keyboard_remote", service: keyboardRemoteProfile.controlServices?.[0] || '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/kiosker/test.kiosker.node.ts b/test/kiosker/test.kiosker.node.ts new file mode 100644 index 0000000..7b5592c --- /dev/null +++ b/test/kiosker/test.kiosker.node.ts @@ -0,0 +1,148 @@ +import { createServer } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KioskerClient, KioskerConfigFlow, KioskerIntegration, KioskerMapper, createKioskerDiscoveryDescriptor, kioskerProfile, type IKioskerSnapshot, type TKioskerRawData } from '../../ts/integrations/kiosker/index.js'; + +const rawData: TKioskerRawData = { + device: { + id: 'kiosker-device-1', + name: "Kiosker Device", + manufacturer: "Kiosker", + model: "Kiosker local integration", + serialNumber: 'kiosker-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "kiosker" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kiosker candidates and creates config flow output', async () => { + const descriptor = createKioskerDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kiosker-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kiosker-device-1', name: "Kiosker Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kiosker"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KioskerConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kiosker-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kiosker raw snapshots to runtime devices and entities', async () => { + const client = new KioskerClient({ name: "Kiosker Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KioskerMapper.toSnapshotFromRaw({ name: "Kiosker Runtime" }, rawData); + const devices = KioskerMapper.toDevices(mappedSnapshot); + const entities = KioskerMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kiosker"); + expect(devices[0].manufacturer).toEqual("Kiosker"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kiosker" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('polls the documented Kiosker HTTP API and maps Home Assistant entities', async () => { + const requests: string[] = []; + const server = createServer((requestArg, responseArg) => { + requests.push(`${requestArg.method} ${requestArg.url} ${requestArg.headers.authorization}`); + if (requestArg.headers.authorization !== 'Bearer test-token') { + responseArg.writeHead(401, { 'content-type': 'application/json' }); + responseArg.end(JSON.stringify({ error: true, reason: 'unauthorized' })); + return; + } + + responseArg.writeHead(200, { 'content-type': 'application/json' }); + if (requestArg.url === '/api/v1/status') { + responseArg.end(JSON.stringify({ + status: { + batteryLevel: 87, + batteryState: 'Charging', + model: 'iPad13,4', + osVersion: '17.5', + appName: 'Kiosker Pro', + appVersion: '25.1.0', + lastInteraction: '2026-01-01T00:00:00+00:00', + lastMotion: null, + ambientLight: 12.5, + date: '2026-01-01T00:00:05+00:00', + deviceId: '2904C1F2-93FB-4954-BF85-FAAEFBA814F6', + }, + })); + return; + } + if (requestArg.url === '/api/v1/blackout/state') { + responseArg.end(JSON.stringify({ blackout: { visible: true, text: 'Maintenance' } })); + return; + } + if (requestArg.url === '/api/v1/screensaver/state') { + responseArg.end(JSON.stringify({ screensaver: { visible: false, disabled: false } })); + return; + } + responseArg.end(JSON.stringify({})); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const config = { host: '127.0.0.1', port, token: 'test-token', name: 'Lobby Kiosk', timeoutMs: 1000 }; + const client = new KioskerClient(config); + const snapshot = await client.getSnapshot(true); + const entities = KioskerMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('2904C1F2-93FB-4954-BF85-FAAEFBA814F6'); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_batterylevel'))?.state).toEqual(87); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_blackoutstate'))?.state).toBeTrue(); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_charging'))?.state).toBeTrue(); + expect([...requests].sort()).toEqual([ + 'GET /api/v1/blackout/state Bearer test-token', + 'GET /api/v1/screensaver/state Bearer test-token', + 'GET /api/v1/status Bearer test-token', + ].sort()); + + const runtime = await new KioskerIntegration().setup(config, {}); + const status = await runtime.callService!({ domain: 'kiosker', service: 'status', target: {} }); + expect(status.success).toBeTrue(); + expect((status.data as IKioskerSnapshot).source).toEqual('http'); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Kiosker runtime and unsupported control without executor', async () => { + const integration = new KioskerIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(kioskerProfile.metadata.configFlow).toEqual(true); + expect(kioskerProfile.metadata.requirements).toEqual([ + "kiosker-python-api==1.2.9", + ]); + expect((kioskerProfile.metadata.localApi as { implemented: string[] }).implemented.some((itemArg) => itemArg.includes('/api/v1/status'))).toBeTrue(); + + const runtime = await integration.setup({ name: "Kiosker Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kiosker", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kiosker", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKioskerSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kiosker Device"); + + const command = await runtime.callService!({ domain: "kiosker", service: kioskerProfile.controlServices?.[0] || '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/kira/test.kira.node.ts b/test/kira/test.kira.node.ts new file mode 100644 index 0000000..75d0710 --- /dev/null +++ b/test/kira/test.kira.node.ts @@ -0,0 +1,96 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KiraClient, KiraConfigFlow, KiraIntegration, KiraMapper, createKiraDiscoveryDescriptor, kiraProfile, type IKiraSnapshot, type TKiraRawData } from '../../ts/integrations/kira/index.js'; + +const rawData: TKiraRawData = { + device: { + id: 'kira-device-1', + name: "Kira Device", + manufacturer: "Kira", + model: "Kira local integration", + serialNumber: 'kira-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "kira" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kira candidates and creates config flow output', async () => { + const descriptor = createKiraDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kira-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kira-device-1', name: "Kira Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kira"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KiraConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kira-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kira raw snapshots to runtime devices and entities', async () => { + const client = new KiraClient({ name: "Kira Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KiraMapper.toSnapshotFromRaw({ name: "Kira Runtime" }, rawData); + const devices = KiraMapper.toDevices(mappedSnapshot); + const entities = KiraMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kira"); + expect(devices[0].manufacturer).toEqual("Kira"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kira" && entityArg.platform === "button")).toBeTrue(); +}); + +tap.test('reads Kira Home Assistant code files as a native file snapshot', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kira-codes-')); + const codesPath = path.join(tempDir, 'kira_codes.yaml'); + await fs.writeFile(codesPath, '- name: Power\n code: ABC123\n device: tv\n type: raw\n repeat: 2\n', 'utf8'); + + try { + const client = new KiraClient({ name: 'Kira Codes', codesPath }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.source).toEqual('client'); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'power' && entityArg.platform === 'button')).toBeTrue(); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'code_count')?.state).toEqual(1); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.attributes?.code).toEqual('ABC123'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +tap.test('exposes Kira runtime and unsupported control without executor', async () => { + const integration = new KiraIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(kiraProfile.metadata.configFlow).toEqual(false); + expect(kiraProfile.metadata.requirements).toEqual([ + "pykira==0.1.1", + ]); + + const runtime = await integration.setup({ name: "Kira Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kira", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kira", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKiraSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kira Device"); + + const command = await runtime.callService!({ domain: "kira", service: kiraProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('pykira over a UDP IR-IP bridge protocol'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/kmtronic/test.kmtronic.node.ts b/test/kmtronic/test.kmtronic.node.ts new file mode 100644 index 0000000..704b870 --- /dev/null +++ b/test/kmtronic/test.kmtronic.node.ts @@ -0,0 +1,103 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KmtronicClient, KmtronicConfigFlow, KmtronicIntegration, KmtronicMapper, createKmtronicDiscoveryDescriptor, kmtronicProfile, type IKmtronicSnapshot, type TKmtronicRawData } from '../../ts/integrations/kmtronic/index.js'; + +const rawData: TKmtronicRawData = { + device: { + id: 'kmtronic-device-1', + name: "KMtronic Device", + manufacturer: "KMtronic", + model: "KMtronic local integration", + serialNumber: 'kmtronic-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "kmtronic" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual KMtronic candidates and creates config flow output', async () => { + const descriptor = createKmtronicDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kmtronic-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kmtronic-device-1', name: "KMtronic Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kmtronic"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KmtronicConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kmtronic-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps KMtronic raw snapshots to runtime devices and entities', async () => { + const client = new KmtronicClient({ name: "KMtronic Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KmtronicMapper.toSnapshotFromRaw({ name: "KMtronic Runtime" }, rawData); + const devices = KmtronicMapper.toDevices(mappedSnapshot); + const entities = KmtronicMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kmtronic"); + expect(devices[0].manufacturer).toEqual("KMtronic"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kmtronic" && entityArg.platform === "switch")).toBeTrue(); +}); + +tap.test('polls and controls KMtronic relays through the pykmtronic HTTP endpoints', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => { + const url = String(inputArg); + calls.push({ url, init: initArg }); + if (url.endsWith('/status.xml')) { + return new Response('ok10', { status: 200 }); + } + return new Response('ok', { status: 200 }); + }) as typeof fetch; + + try { + const client = new KmtronicClient({ host: 'relay.local', username: 'admin', password: 'secret', name: 'Relay Controller' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'switch', service: 'turn_off', target: {}, data: { relayId: 1 } }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'relay_1')?.state).toBeTrue(); + expect(command.success).toBeTrue(); + expect(calls[0].url).toEqual('http://relay.local/status.xml'); + expect(String((calls[0].init?.headers as Record).authorization)).toContain('Basic '); + expect(calls[1].url).toEqual('http://relay.local/FF0100'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes KMtronic runtime and unsupported control without executor', async () => { + const integration = new KmtronicIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(kmtronicProfile.metadata.configFlow).toEqual(true); + expect(kmtronicProfile.metadata.requirements).toEqual([ + "pykmtronic==0.3.0", + ]); + + const runtime = await integration.setup({ name: "KMtronic Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kmtronic", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kmtronic", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKmtronicSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("KMtronic Device"); + + const command = await runtime.callService!({ domain: "kmtronic", service: kmtronicProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires config.host/config.url'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/konnected/test.konnected.node.ts b/test/konnected/test.konnected.node.ts new file mode 100644 index 0000000..b03e63b --- /dev/null +++ b/test/konnected/test.konnected.node.ts @@ -0,0 +1,118 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KonnectedClient, KonnectedConfigFlow, KonnectedIntegration, KonnectedMapper, createKonnectedDiscoveryDescriptor, konnectedProfile, type IKonnectedSnapshot, type TKonnectedRawData } from '../../ts/integrations/konnected/index.js'; + +const rawData: TKonnectedRawData = { + device: { + id: 'konnected-device-1', + name: "Konnected.io (Legacy) Device", + manufacturer: "Konnected.io (Legacy)", + model: "Konnected.io (Legacy) local integration", + serialNumber: 'konnected-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "konnected" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Konnected.io (Legacy) candidates and creates config flow output', async () => { + const descriptor = createKonnectedDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'konnected-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'konnected-device-1', name: "Konnected.io (Legacy) Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("konnected"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KonnectedConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('konnected-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Konnected.io (Legacy) raw snapshots to runtime devices and entities', async () => { + const client = new KonnectedClient({ name: "Konnected.io (Legacy) Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KonnectedMapper.toSnapshotFromRaw({ name: "Konnected.io (Legacy) Runtime" }, rawData); + const devices = KonnectedMapper.toDevices(mappedSnapshot); + const entities = KonnectedMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("konnected"); + expect(devices[0].manufacturer).toEqual("Konnected.io (Legacy)"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "konnected" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('polls and controls Konnected panels through the documented HTTP API', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => { + const url = String(inputArg); + calls.push({ url, init: initArg }); + if (url.endsWith('/status')) { + return new Response(JSON.stringify({ + mac: '2c:3a:e8:43:8a:38', + model: 'Konnected Pro', + swVersion: '2.1.3', + sensors: [{ zone: '1', state: 1 }], + actuators: [{ zone: 'out1', state: 0, trigger: 1 }], + rssi: -31, + }), { status: 200 }); + } + if (url.includes('/zone?')) { + return new Response(JSON.stringify([{ zone: 'out1', state: 0 }]), { status: 200 }); + } + return new Response(JSON.stringify({ zone: 'out1', state: 1 }), { status: 200 }); + }) as typeof fetch; + + try { + const client = new KonnectedClient({ host: 'panel.local', port: 17000, name: 'Alarm Panel' }); + const snapshot = await client.getSnapshot(true); + const command = await client.execute({ domain: 'switch', service: 'turn_on', target: {}, data: { zone: 'out1' } }); + const toggle = await client.execute({ domain: 'switch', service: 'toggle', target: {}, data: { zone: 'out1' } }); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'zone_1' && entityArg.state === true)).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'actuator_zone_out1' && entityArg.platform === 'switch')).toBeTrue(); + expect(command.success).toBeTrue(); + expect(toggle.success).toBeTrue(); + expect(calls[0].url).toEqual('http://panel.local:17000/status'); + expect(calls[1].url).toEqual('http://panel.local:17000/zone'); + expect(JSON.parse(String(calls[1].init?.body))).toEqual({ zone: 'out1', state: 1 }); + expect(calls[2].url).toEqual('http://panel.local:17000/zone?zone=out1'); + expect(JSON.parse(String(calls[3].init?.body))).toEqual({ zone: 'out1', state: 1 }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Konnected.io (Legacy) runtime and unsupported control without executor', async () => { + const integration = new KonnectedIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(konnectedProfile.metadata.configFlow).toEqual(true); + expect(konnectedProfile.metadata.requirements).toEqual([ + "konnected==1.2.0", + ]); + + const runtime = await integration.setup({ name: "Konnected.io (Legacy) Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "konnected", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "konnected", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKonnectedSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Konnected.io (Legacy) Device"); + + const command = await runtime.callService!({ domain: "konnected", service: konnectedProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires config.host/config.url'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/kostal_plenticore/test.kostal_plenticore.client.node.ts b/test/kostal_plenticore/test.kostal_plenticore.client.node.ts new file mode 100644 index 0000000..9e044bb --- /dev/null +++ b/test/kostal_plenticore/test.kostal_plenticore.client.node.ts @@ -0,0 +1,134 @@ +import * as crypto from 'node:crypto'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KostalPlenticoreClient } from '../../ts/integrations/kostal_plenticore/index.js'; + +tap.test('authenticates to Kostal Plenticore REST API and reads/writes live data', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ path: string; method: string; authorization?: string; body?: unknown }> = []; + const password = 'secret-password'; + const user = 'user'; + const salt = Buffer.from('test-salt-for-pbkdf'); + const serverNonce = Buffer.from('server-nonce-12'); + const transactionId = Buffer.from('transaction-id-1'); + const rounds = 2; + let clientNonce = Buffer.alloc(0); + + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + const url = new URL(String(urlArg)); + const body = parseBody(initArg?.body); + const authorization = headerValue(initArg?.headers, 'authorization'); + calls.push({ path: url.pathname, method: initArg?.method || 'GET', authorization, body }); + + if (url.pathname === '/api/v1/auth/start') { + clientNonce = Buffer.from(String((body as Record).nonce), 'base64'); + return jsonResponse({ nonce: b64(serverNonce), transactionId: b64(transactionId), salt: b64(salt), rounds }); + } + if (url.pathname === '/api/v1/auth/finish') { + return jsonResponse({ token: 'session-token', signature: b64(serverSignature(password, user, clientNonce, serverNonce, salt, rounds)) }); + } + if (url.pathname === '/api/v1/auth/create_session') { + expect(typeof (body as Record).payload).toEqual('string'); + return jsonResponse({ sessionId: 'SESSION1' }); + } + if (url.pathname === '/api/v1/info/version') { + expect(authorization).toEqual('Session SESSION1'); + return jsonResponse({ hostname: 'kostal-host', name: 'Plenticore API', sw_version: 'FW1', api_version: '1' }); + } + if (url.pathname === '/api/v1/processdata') { + expect(authorization).toEqual('Session SESSION1'); + return jsonResponse([ + { moduleid: 'devices:local', processdata: [{ id: 'Inverter:State', unit: '', value: 6 }, { id: 'Dc_P', unit: 'W', value: 1234.4 }] }, + { moduleid: 'devices:local:pv1', processdata: [{ id: 'U', unit: 'V', value: 398.2 }] }, + ]); + } + if (url.pathname === '/api/v1/settings' && initArg?.method === 'POST') { + expect(authorization).toEqual('Session SESSION1'); + return jsonResponse([ + { + moduleid: 'devices:local', + settings: [ + { id: 'Properties:SerialNo', value: 'SN123' }, + { id: 'Branding:ProductName1', value: 'PLENTICORE' }, + { id: 'Branding:ProductName2', value: 'plus' }, + { id: 'Properties:VersionIOC', value: 'IOC1' }, + { id: 'Properties:VersionMC', value: 'MC1' }, + { id: 'Battery:MinSoc', value: '10' }, + { id: 'Battery:SmartBatteryControl:Enable', value: '1' }, + { id: 'Battery:TimeControl:Enable', value: '0' }, + ], + }, + { moduleid: 'scb:network', settings: [{ id: 'Hostname', value: 'Kostal Test' }] }, + ]); + } + if (url.pathname === '/api/v1/settings' && initArg?.method === 'PUT') { + expect(authorization).toEqual('Session SESSION1'); + return jsonResponse({ ok: true }); + } + if (url.pathname === '/api/v1/auth/logout') { + return jsonResponse({ ok: true }); + } + return jsonResponse({ message: 'not found' }, 404); + }) as typeof globalThis.fetch; + + try { + const client = new KostalPlenticoreClient({ + host: 'inverter.local', + password, + timeoutMs: 1000, + processData: { 'devices:local': ['Inverter:State', 'Dc_P'], 'devices:local:pv1': ['U'] }, + }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('SN123'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'devices_local_dc_p')?.state).toEqual(1234); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'devices_local_inverter_state')?.state).toEqual('FeedIn'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'battery_min_soc')?.state).toEqual(10); + + const write = await client.execute({ domain: 'number', service: 'set_value', target: {}, data: { moduleId: 'devices:local', dataId: 'Battery:MinSoc', value: 25 } }); + expect(write.success).toBeTrue(); + const settingsWrite = calls.find((callArg) => callArg.path === '/api/v1/settings' && callArg.method === 'PUT'); + expect(settingsWrite?.body).toEqual([{ moduleid: 'devices:local', settings: [{ id: 'Battery:MinSoc', value: '25' }] }]); + expect(calls.some((callArg) => callArg.path === '/api/v1/auth/start')).toBeTrue(); + expect(calls.some((callArg) => callArg.path === '/api/v1/processdata')).toBeTrue(); + await client.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +const serverSignature = (passwordArg: string, userArg: string, clientNonceArg: Buffer, serverNonceArg: Buffer, saltArg: Buffer, roundsArg: number): Buffer => { + const saltedPassword = crypto.pbkdf2Sync(Buffer.from(passwordArg, 'utf8'), saltArg, roundsArg, 32, 'sha256'); + const serverKey = hmac(saltedPassword, 'Server Key'); + const authMessage = `n=${userArg},r=${b64(clientNonceArg)},r=${b64(serverNonceArg)},s=${b64(saltArg)},i=${roundsArg},c=biws,r=${b64(serverNonceArg)}`; + return hmac(serverKey, authMessage); +}; + +const hmac = (keyArg: Buffer, messageArg: string): Buffer => crypto.createHmac('sha256', keyArg).update(Buffer.from(messageArg, 'utf8')).digest(); + +const b64 = (bufferArg: Buffer): string => bufferArg.toString('base64'); + +const jsonResponse = (valueArg: unknown, statusArg = 200): Response => new Response(JSON.stringify(valueArg), { status: statusArg, headers: { 'content-type': 'application/json' } }); + +const parseBody = (bodyArg: BodyInit | null | undefined): unknown => { + if (typeof bodyArg === 'string') { + return JSON.parse(bodyArg) as unknown; + } + return undefined; +}; + +const headerValue = (headersArg: HeadersInit | undefined, nameArg: string): string | undefined => { + if (!headersArg) { + return undefined; + } + if (headersArg instanceof Headers) { + return headersArg.get(nameArg) || undefined; + } + if (Array.isArray(headersArg)) { + return headersArg.find(([keyArg]) => keyArg.toLowerCase() === nameArg)?.[1]; + } + return (headersArg as Record)[nameArg]; +}; + +export default tap.start(); diff --git a/test/kostal_plenticore/test.kostal_plenticore.node.ts b/test/kostal_plenticore/test.kostal_plenticore.node.ts new file mode 100644 index 0000000..3f46c1f --- /dev/null +++ b/test/kostal_plenticore/test.kostal_plenticore.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KostalPlenticoreClient, KostalPlenticoreConfigFlow, KostalPlenticoreIntegration, KostalPlenticoreMapper, createKostalPlenticoreDiscoveryDescriptor, kostalPlenticoreProfile, type IKostalPlenticoreSnapshot, type TKostalPlenticoreRawData } from '../../ts/integrations/kostal_plenticore/index.js'; + +const rawData: TKostalPlenticoreRawData = { + device: { + id: 'kostal_plenticore-device-1', + name: "Kostal Plenticore Solar Inverter Device", + manufacturer: "Kostal Plenticore Solar Inverter", + model: "Kostal Plenticore Solar Inverter local integration", + serialNumber: 'kostal_plenticore-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "number", state: true, attributes: { domain: "kostal_plenticore" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kostal Plenticore Solar Inverter candidates and creates config flow output', async () => { + const descriptor = createKostalPlenticoreDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kostal_plenticore-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kostal_plenticore-device-1', name: "Kostal Plenticore Solar Inverter Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kostal_plenticore"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KostalPlenticoreConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kostal_plenticore-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kostal Plenticore Solar Inverter raw snapshots to runtime devices and entities', async () => { + const client = new KostalPlenticoreClient({ name: "Kostal Plenticore Solar Inverter Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KostalPlenticoreMapper.toSnapshotFromRaw({ name: "Kostal Plenticore Solar Inverter Runtime" }, rawData); + const devices = KostalPlenticoreMapper.toDevices(mappedSnapshot); + const entities = KostalPlenticoreMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kostal_plenticore"); + expect(devices[0].manufacturer).toEqual("Kostal Plenticore Solar Inverter"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kostal_plenticore" && entityArg.platform === "number")).toBeTrue(); +}); + +tap.test('exposes Kostal Plenticore Solar Inverter runtime and unsupported control without executor', async () => { + const integration = new KostalPlenticoreIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(kostalPlenticoreProfile.metadata.configFlow).toEqual(true); + expect(kostalPlenticoreProfile.metadata.requirements).toEqual([ + "pykoplenti==1.5.0", + ]); + + const runtime = await integration.setup({ name: "Kostal Plenticore Solar Inverter Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kostal_plenticore", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kostal_plenticore", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKostalPlenticoreSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kostal Plenticore Solar Inverter Device"); + + const command = await runtime.callService!({ domain: "kostal_plenticore", service: kostalPlenticoreProfile.controlServices?.[0] || '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/kulersky/test.kulersky.node.ts b/test/kulersky/test.kulersky.node.ts new file mode 100644 index 0000000..50c0518 --- /dev/null +++ b/test/kulersky/test.kulersky.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KulerskyClient, KulerskyConfigFlow, KulerskyIntegration, KulerskyMapper, createKulerskyDiscoveryDescriptor, kulerskyProfile, type IKulerskySnapshot, type TKulerskyRawData } from '../../ts/integrations/kulersky/index.js'; + +const rawData: TKulerskyRawData = { + device: { + id: 'kulersky-device-1', + name: "Kuler Sky Device", + manufacturer: "Kuler Sky", + model: "Kuler Sky local integration", + serialNumber: 'kulersky-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "light", state: true, attributes: { domain: "kulersky" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Kuler Sky candidates and creates config flow output', async () => { + const descriptor = createKulerskyDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kulersky-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kulersky-device-1', name: "Kuler Sky Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kulersky"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KulerskyConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kulersky-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Kuler Sky raw snapshots to runtime devices and entities', async () => { + const client = new KulerskyClient({ name: "Kuler Sky Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KulerskyMapper.toSnapshotFromRaw({ name: "Kuler Sky Runtime" }, rawData); + const devices = KulerskyMapper.toDevices(mappedSnapshot); + const entities = KulerskyMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kulersky"); + expect(devices[0].manufacturer).toEqual("Kuler Sky"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kulersky" && entityArg.platform === "light")).toBeTrue(); +}); + +tap.test('exposes Kuler Sky runtime and unsupported control without executor', async () => { + const integration = new KulerskyIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(kulerskyProfile.metadata.configFlow).toEqual(true); + expect(kulerskyProfile.metadata.requirements).toEqual([ + "pykulersky==0.5.8", + ]); + + const runtime = await integration.setup({ name: "Kuler Sky Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kulersky", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kulersky", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKulerskySnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Kuler Sky Device"); + + const command = await runtime.callService!({ domain: "kulersky", service: kulerskyProfile.controlServices?.[0] || '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/kulersky/test.kulersky.unsupported.node.ts b/test/kulersky/test.kulersky.unsupported.node.ts new file mode 100644 index 0000000..f934a0a --- /dev/null +++ b/test/kulersky/test.kulersky.unsupported.node.ts @@ -0,0 +1,10 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { kulerskyProfile } from '../../ts/integrations/kulersky/index.js'; + +tap.test('documents Kuler Sky native BLE blocker instead of faking live transport', async () => { + const localApi = kulerskyProfile.metadata.localApi as { status: string; explicitUnsupported: string[] }; + expect(localApi.status).toInclude('pykulersky over bleak/GATT Bluetooth'); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('no BLE stack is available'))).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/kwb/test.kwb.client.node.ts b/test/kwb/test.kwb.client.node.ts new file mode 100644 index 0000000..7954695 --- /dev/null +++ b/test/kwb/test.kwb.client.node.ts @@ -0,0 +1,50 @@ +import { createServer } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KwbClient, KwbIntegration, type IKwbSnapshot } from '../../ts/integrations/kwb/index.js'; + +tap.test('reads KWB Easyfire TCP byte-stream packets into sensor snapshots', async () => { + const payload = Buffer.concat([sensePacket([21.5, 20.1, 64.4, 250.2, 48, 47.5, -3.2, 120.5, 12.3, 0, 0, 0, 55.5]), controlPacket()]); + const server = createServer((socketArg) => socketArg.end(payload)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const client = new KwbClient({ host: '127.0.0.1', port, timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'supply')?.state).toEqual(21.5); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'return_mixer')?.state).toEqual(1); + + const runtime = await new KwbIntegration().setup({ host: '127.0.0.1', port, timeoutMs: 1000 }, {}); + const status = await runtime.callService!({ domain: 'kwb', service: 'status', target: {} }); + expect(status.success).toBeTrue(); + expect((status.data as IKwbSnapshot).entities.find((entityArg) => entityArg.id === 'furnace')?.state).toEqual(250.2); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +const sensePacket = (temperaturesArg: number[]): Buffer => { + const tempBytes = Buffer.concat(temperaturesArg.map((valueArg) => { + const encoded = Math.round(valueArg * 10); + const buffer = Buffer.alloc(2); + buffer.writeInt16BE(encoded, 0); + return buffer; + })); + const unescapedData = Buffer.concat([Buffer.alloc(4), tempBytes, Buffer.alloc(6)]); + const packet = Buffer.concat([Buffer.from([0]), unescapedData]); + return Buffer.concat([Buffer.from([2, 2, packet.length, 1, 1]), packet, Buffer.from([0])]); +}; + +const controlPacket = (): Buffer => { + const packet = Buffer.alloc(16); + packet[2] = 0b00000010; + packet[3] = 0b00000010; + return Buffer.concat([Buffer.from([2, 1, 1, 1]), packet, Buffer.from([0])]); +}; + +export default tap.start(); diff --git a/test/kwb/test.kwb.node.ts b/test/kwb/test.kwb.node.ts new file mode 100644 index 0000000..1857af2 --- /dev/null +++ b/test/kwb/test.kwb.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KwbClient, KwbConfigFlow, KwbIntegration, KwbMapper, createKwbDiscoveryDescriptor, kwbProfile, type IKwbSnapshot, type TKwbRawData } from '../../ts/integrations/kwb/index.js'; + +const rawData: TKwbRawData = { + device: { + id: 'kwb-device-1', + name: "KWB Easyfire Device", + manufacturer: "KWB Easyfire", + model: "KWB Easyfire local integration", + serialNumber: 'kwb-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "kwb" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual KWB Easyfire candidates and creates config flow output', async () => { + const descriptor = createKwbDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kwb-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'kwb-device-1', name: "KWB Easyfire Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("kwb"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new KwbConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('kwb-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps KWB Easyfire raw snapshots to runtime devices and entities', async () => { + const client = new KwbClient({ name: "KWB Easyfire Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = KwbMapper.toSnapshotFromRaw({ name: "KWB Easyfire Runtime" }, rawData); + const devices = KwbMapper.toDevices(mappedSnapshot); + const entities = KwbMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("kwb"); + expect(devices[0].manufacturer).toEqual("KWB Easyfire"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "kwb" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes KWB Easyfire runtime and unsupported control without executor', async () => { + const integration = new KwbIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(kwbProfile.metadata.configFlow).toEqual(false); + expect(kwbProfile.metadata.requirements).toEqual([ + "pykwb==0.0.8", + ]); + + const runtime = await integration.setup({ name: "KWB Easyfire Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "kwb", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "kwb", service: 'refresh', target: {} }); + const snapshot = statusResult.data as IKwbSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("KWB Easyfire Device"); + + const command = await runtime.callService!({ domain: "kwb", service: kwbProfile.controlServices?.[0] || '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/lacrosse/test.lacrosse.node.ts b/test/lacrosse/test.lacrosse.node.ts new file mode 100644 index 0000000..894557b --- /dev/null +++ b/test/lacrosse/test.lacrosse.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LacrosseClient, LacrosseConfigFlow, LacrosseIntegration, LacrosseMapper, createLacrosseDiscoveryDescriptor, lacrosseProfile, type ILacrosseSnapshot, type TLacrosseRawData } from '../../ts/integrations/lacrosse/index.js'; + +const rawData: TLacrosseRawData = { + device: { + id: 'lacrosse-device-1', + name: "LaCrosse Device", + manufacturer: "LaCrosse", + model: "LaCrosse local integration", + serialNumber: 'lacrosse-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "lacrosse" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LaCrosse candidates and creates config flow output', async () => { + const descriptor = createLacrosseDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lacrosse-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lacrosse-device-1', name: "LaCrosse Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lacrosse"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LacrosseConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lacrosse-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LaCrosse raw snapshots to runtime devices and entities', async () => { + const client = new LacrosseClient({ name: "LaCrosse Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LacrosseMapper.toSnapshotFromRaw({ name: "LaCrosse Runtime" }, rawData); + const devices = LacrosseMapper.toDevices(mappedSnapshot); + const entities = LacrosseMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lacrosse"); + expect(devices[0].manufacturer).toEqual("LaCrosse"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lacrosse" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes LaCrosse runtime and unsupported control without executor', async () => { + const integration = new LacrosseIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(lacrosseProfile.metadata.configFlow).toEqual(false); + expect(lacrosseProfile.metadata.requirements).toEqual([ + "pylacrosse==0.4", + ]); + expect(JSON.stringify(lacrosseProfile.metadata.localApi)).toContain('pyserial'); + expect(JSON.stringify(lacrosseProfile.metadata.localApi)).toContain('RFC2217'); + + const runtime = await integration.setup({ name: "LaCrosse Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lacrosse", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lacrosse", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILacrosseSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LaCrosse Device"); + + const command = await runtime.callService!({ domain: "lacrosse", service: lacrosseProfile.controlServices?.[0] || '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/lametric/test.lametric.node.ts b/test/lametric/test.lametric.node.ts new file mode 100644 index 0000000..8d31f0f --- /dev/null +++ b/test/lametric/test.lametric.node.ts @@ -0,0 +1,215 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LametricClient, LametricConfigFlow, LametricIntegration, LametricMapper, createLametricDiscoveryDescriptor, lametricProfile, type ILametricSnapshot, type TLametricRawData } from '../../ts/integrations/lametric/index.js'; + +const readBody = async (requestArg: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const rawData: TLametricRawData = { + device: { + id: 'lametric-device-1', + name: "LaMetric Device", + manufacturer: "LaMetric", + model: "LaMetric local integration", + serialNumber: 'lametric-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "lametric" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const deviceData = { + id: 'cloud-device-1', + name: 'Office LaMetric', + serial_number: 'SA150600000100W00BS9', + mode: 'manual', + model: 'LM 37X8', + os_version: '2.3.0', + update_available: { version: '2.3.1' }, + audio: { volume: 69, volume_range: { min: 0, max: 100 }, volume_limit: { min: 0, max: 80 } }, + bluetooth: { active: true, available: true, discoverable: false, mac: '58:63:56:23:95:6C', name: 'Office LaMetric', pairable: true }, + display: { brightness: 67, brightness_mode: 'auto', brightness_range: { min: 0, max: 100 }, brightness_limit: { min: 2, max: 75 }, width: 37, height: 8, type: 'mixed' }, + wifi: { active: true, available: true, essid: 'office', ip: '192.168.1.50', address: '58:63:56:10:D6:1F', strength: 88, mode: 'dhcp', netmask: '255.255.255.0' }, +}; + +const startLametricServer = async (): Promise<{ url: string; requests: Array<{ method?: string; path: string; body?: unknown }>; close(): Promise }> => { + const requests: Array<{ method?: string; path: string; body?: unknown }> = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + void (async () => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + const bodyText = ['POST', 'PUT'].includes(requestArg.method || '') ? await readBody(requestArg) : undefined; + const body = bodyText ? JSON.parse(bodyText) : undefined; + requests.push({ method: requestArg.method, path: url.pathname, body }); + + expect(requestArg.headers.authorization).toEqual(`Basic ${Buffer.from('dev:api-key').toString('base64')}`); + + if (url.pathname === '/api/v2/device' && requestArg.method === 'GET') { + json(responseArg, deviceData); + return; + } + if (url.pathname === '/api/v2/device/display' && requestArg.method === 'PUT') { + json(responseArg, { success: { data: { ...deviceData.display, ...body }, path: url.pathname } }); + return; + } + if (url.pathname === '/api/v2/device/audio' && requestArg.method === 'PUT') { + json(responseArg, { success: { data: { ...deviceData.audio, ...body }, path: url.pathname } }); + return; + } + if (url.pathname === '/api/v2/device/bluetooth' && requestArg.method === 'PUT') { + json(responseArg, { success: { data: { ...deviceData.bluetooth, ...body }, path: url.pathname } }); + return; + } + if (url.pathname === '/api/v2/device/apps/next' && requestArg.method === 'PUT') { + json(responseArg, { success: { data: {}, path: url.pathname } }); + return; + } + if (url.pathname === '/api/v2/device/notifications/current' && requestArg.method === 'GET') { + json(responseArg, { id: 7, priority: 'info', model: { frames: [{ text: 'Now' }] } }); + return; + } + if (url.pathname === '/api/v2/device/notifications/7' && requestArg.method === 'DELETE') { + json(responseArg, { success: true }); + return; + } + if (url.pathname === '/api/v2/device/notifications' && requestArg.method === 'POST') { + json(responseArg, { success: { id: 9 } }); + return; + } + responseArg.statusCode = 404; + responseArg.end('{}'); + })().catch((errorArg) => { + responseArg.statusCode = 500; + responseArg.end(String(errorArg)); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: async () => new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())), + }; +}; + +tap.test('matches manual LaMetric candidates and creates config flow output', async () => { + const descriptor = createLametricDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lametric-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lametric-device-1', name: "LaMetric Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lametric"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LametricConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lametric-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LaMetric raw snapshots to runtime devices and entities', async () => { + const client = new LametricClient({ name: "LaMetric Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LametricMapper.toSnapshotFromRaw({ name: "LaMetric Runtime" }, rawData); + const devices = LametricMapper.toDevices(mappedSnapshot); + const entities = LametricMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lametric"); + expect(devices[0].manufacturer).toEqual("LaMetric"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lametric" && entityArg.platform === "button")).toBeTrue(); +}); + +tap.test('reads and controls LaMetric through the local Device API', async () => { + const server = await startLametricServer(); + try { + const client = new LametricClient({ url: server.url, protocol: 'http', apiKey: 'api-key', timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LametricIntegration().setup({ url: server.url, protocol: 'http', apiKey: 'api-key', timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + const brightness = entities.find((entityArg) => entityArg.attributes?.key === 'brightness')!; + const brightnessMode = entities.find((entityArg) => entityArg.attributes?.key === 'brightness_mode')!; + const volume = entities.find((entityArg) => entityArg.attributes?.key === 'volume')!; + const bluetooth = entities.find((entityArg) => entityArg.attributes?.key === 'bluetooth')!; + const nextApp = entities.find((entityArg) => entityArg.attributes?.key === 'app_next')!; + const dismissCurrent = entities.find((entityArg) => entityArg.attributes?.key === 'dismiss_current')!; + + const brightnessResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: brightness.id }, data: { value: 42 } }); + const modeResult = await runtime.callService!({ domain: 'select', service: 'select_option', target: { entityId: brightnessMode.id }, data: { option: 'manual' } }); + const volumeResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: volume.id }, data: { value: 20 } }); + const bluetoothResult = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: bluetooth.id } }); + const nextResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: nextApp.id } }); + const dismissResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: dismissCurrent.id } }); + const messageResult = await runtime.callService!({ domain: 'lametric', service: 'message', target: {}, data: { message: 'Hello', icon: '7956', cycles: 2, sound: 'win' } }); + const chartResult = await runtime.callService!({ domain: 'lametric', service: 'chart', target: {}, data: { data: [1, 2, 3], priority: 'warning' } }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('SA150600000100W00BS9'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'rssi')?.state).toEqual(88); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'update')?.attributes?.latestVersion).toEqual('2.3.1'); + expect(brightnessResult.success).toBeTrue(); + expect(modeResult.success).toBeTrue(); + expect(volumeResult.success).toBeTrue(); + expect(bluetoothResult.success).toBeTrue(); + expect(nextResult.success).toBeTrue(); + expect(dismissResult.success).toBeTrue(); + expect(messageResult.success).toBeTrue(); + expect(chartResult.success).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/display' && (requestArg.body as Record)?.brightness === 42)).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/display' && (requestArg.body as Record)?.brightness_mode === 'manual')).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/audio' && (requestArg.body as Record)?.volume === 20)).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/bluetooth' && (requestArg.body as Record)?.active === false)).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/apps/next')).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications/7' && requestArg.method === 'DELETE')).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications' && requestArg.method === 'POST' && JSON.stringify(requestArg.body).includes('Hello'))).toBeTrue(); + expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications' && requestArg.method === 'POST' && JSON.stringify(requestArg.body).includes('chartData'))).toBeTrue(); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +tap.test('exposes LaMetric runtime and unsupported control without executor', async () => { + const integration = new LametricIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(lametricProfile.metadata.configFlow).toEqual(true); + expect(lametricProfile.metadata.requirements).toEqual([ + "demetriek==1.3.0", + ]); + + const runtime = await integration.setup({ name: "LaMetric Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lametric", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lametric", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILametricSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LaMetric Device"); + + const command = await runtime.callService!({ domain: "lametric", service: lametricProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('Static snapshots/manual data are read-only'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/landisgyr_heat_meter/test.landisgyr_heat_meter.node.ts b/test/landisgyr_heat_meter/test.landisgyr_heat_meter.node.ts new file mode 100644 index 0000000..7e4f772 --- /dev/null +++ b/test/landisgyr_heat_meter/test.landisgyr_heat_meter.node.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LandisgyrHeatMeterClient, LandisgyrHeatMeterConfigFlow, LandisgyrHeatMeterIntegration, LandisgyrHeatMeterMapper, createLandisgyrHeatMeterDiscoveryDescriptor, landisgyrHeatMeterProfile, type ILandisgyrHeatMeterSnapshot, type TLandisgyrHeatMeterRawData } from '../../ts/integrations/landisgyr_heat_meter/index.js'; + +const rawData: TLandisgyrHeatMeterRawData = { + device: { + id: 'landisgyr_heat_meter-device-1', + name: "Landis+Gyr Heat Meter Device", + manufacturer: "Landis+Gyr Heat Meter", + model: "Landis+Gyr Heat Meter local integration", + serialNumber: 'landisgyr_heat_meter-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "landisgyr_heat_meter" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +const heatMeterFile = `/LUGCUH50\n6.8(12.345*GJ)6.26(00123.456*m3)9.21(OWNER-1)6.26*01(100.100*m3)6.8*01(11.100*GJ)F(00000000)9.20(66153690)6.35(60*m)6.6(25.5*kW)6.6*01(20.5*kW)6.33(3.2*m3ph)9.4(70.1*C&40.2*C)6.31(1200*h)6.32(5*h)6.32*01(4*h)6.36(2026-01-01)6.33*01(2.2*m3ph)9.4*01(65.1*C&35.2*C)6.36*02(2026-02-01)9.36(2026-03-04&05:06:07)9.24(4.0*m3ph)9.1(SET&FW)9.31(1100*h)!\n`; + +tap.test('matches manual Landis+Gyr Heat Meter candidates and creates config flow output', async () => { + const descriptor = createLandisgyrHeatMeterDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'landisgyr_heat_meter-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'landisgyr_heat_meter-device-1', name: "Landis+Gyr Heat Meter Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("landisgyr_heat_meter"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LandisgyrHeatMeterConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('landisgyr_heat_meter-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Landis+Gyr Heat Meter raw snapshots to runtime devices and entities', async () => { + const client = new LandisgyrHeatMeterClient({ name: "Landis+Gyr Heat Meter Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LandisgyrHeatMeterMapper.toSnapshotFromRaw({ name: "Landis+Gyr Heat Meter Runtime" }, rawData); + const devices = LandisgyrHeatMeterMapper.toDevices(mappedSnapshot); + const entities = LandisgyrHeatMeterMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("landisgyr_heat_meter"); + expect(devices[0].manufacturer).toEqual("Landis+Gyr Heat Meter"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "landisgyr_heat_meter" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads documented ultraheat-api file snapshots and parses meter sensors', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'landisgyr-')); + const filePath = path.join(directory, 'LUGCUH50_dummy.txt'); + await fs.writeFile(filePath, heatMeterFile, 'utf8'); + + try { + const client = new LandisgyrHeatMeterClient({ filePath, name: 'Utility Heat Meter' }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LandisgyrHeatMeterIntegration().setup({ filePath, name: 'Utility Heat Meter' }, {}); + const entities = await runtime.entities(); + const refresh = await runtime.callService!({ domain: 'landisgyr_heat_meter', service: 'refresh', target: {} }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('file'); + expect(snapshot.device.model).toEqual('LUGCUH50'); + expect(snapshot.device.serialNumber).toEqual('66153690'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'heat_usage_gj')?.state).toEqual(12.345); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'volume_usage_m3')?.state).toEqual(123.456); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'meter_date_time')?.state).toEqual('2026-03-04T05:06:07.000Z'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'device_number')?.state).toEqual('66153690'); + expect(refresh.success).toBeTrue(); + await runtime.destroy(); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +tap.test('exposes Landis+Gyr Heat Meter runtime and unsupported control without executor', async () => { + const integration = new LandisgyrHeatMeterIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(landisgyrHeatMeterProfile.metadata.configFlow).toEqual(true); + expect(landisgyrHeatMeterProfile.metadata.requirements).toEqual([ + "ultraheat-api==0.5.7", + ]); + expect(JSON.stringify(landisgyrHeatMeterProfile.metadata.localApi)).toContain('pyserial'); + + const runtime = await integration.setup({ name: "Landis+Gyr Heat Meter Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "landisgyr_heat_meter", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "landisgyr_heat_meter", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILandisgyrHeatMeterSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Landis+Gyr Heat Meter Device"); + + const command = await runtime.callService!({ domain: "landisgyr_heat_meter", service: landisgyrHeatMeterProfile.controlServices?.[0] || '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/lannouncer/test.lannouncer.node.ts b/test/lannouncer/test.lannouncer.node.ts new file mode 100644 index 0000000..eb69b8e --- /dev/null +++ b/test/lannouncer/test.lannouncer.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLannouncerIntegration, LannouncerClient, LannouncerConfigFlow, LannouncerIntegration, LannouncerMapper, createLannouncerDiscoveryDescriptor, lannouncerProfile, type ILannouncerSnapshot, type TLannouncerRawData } from '../../ts/integrations/lannouncer/index.js'; + +const rawData: TLannouncerRawData = { + device: { + id: 'lannouncer-device-1', + name: "LANnouncer Device", + manufacturer: "LANnouncer", + model: "LANnouncer local integration", + serialNumber: 'lannouncer-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "lannouncer" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LANnouncer candidates and creates config flow output', async () => { + const descriptor = createLannouncerDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lannouncer-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lannouncer-device-1', name: "LANnouncer Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lannouncer"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LannouncerConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lannouncer-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LANnouncer raw snapshots to runtime devices and entities', async () => { + const client = new LannouncerClient({ name: "LANnouncer Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LannouncerMapper.toSnapshotFromRaw({ name: "LANnouncer Runtime" }, rawData); + const devices = LannouncerMapper.toDevices(mappedSnapshot); + const entities = LannouncerMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lannouncer"); + expect(devices[0].manufacturer).toEqual("LANnouncer"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lannouncer" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes LANnouncer runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LannouncerIntegration(); + const alias = new HomeAssistantLannouncerIntegration(); + expect(alias instanceof LannouncerIntegration).toBeTrue(); + expect(alias.domain).toEqual("lannouncer"); + expect(integration.status).toEqual("read-only-runtime"); + expect(lannouncerProfile.metadata.configFlow).toEqual(false); + expect(lannouncerProfile.metadata.requirements).toEqual([]); + const localApi = lannouncerProfile.metadata.localApi as { status: string; explicitUnsupported: string[] }; + expect(localApi.status).toContain('no native live client'); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('integration_removed repair issue'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LANnouncer Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lannouncer", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lannouncer", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILannouncerSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LANnouncer Device"); + + const command = await runtime.callService!({ domain: "lannouncer", service: lannouncerProfile.controlServices?.[0] || '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/lcn/test.lcn.node.ts b/test/lcn/test.lcn.node.ts new file mode 100644 index 0000000..a2386da --- /dev/null +++ b/test/lcn/test.lcn.node.ts @@ -0,0 +1,198 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../../ts/plugins.js'; +import { HomeAssistantLcnIntegration, LcnClient, LcnConfigFlow, LcnIntegration, LcnMapper, createLcnDiscoveryDescriptor, lcnProfile, type ILcnSnapshot, type TLcnRawData } from '../../ts/integrations/lcn/index.js'; + +const rawData: TLcnRawData = { + device: { + id: 'lcn-device-1', + name: "LCN Device", + manufacturer: "LCN", + model: "LCN local integration", + serialNumber: 'lcn-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lcn" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LCN candidates and creates config flow output', async () => { + const descriptor = createLcnDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lcn-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lcn-device-1', name: "LCN Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lcn"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LcnConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lcn-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LCN raw snapshots to runtime devices and entities', async () => { + const client = new LcnClient({ name: "LCN Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LcnMapper.toSnapshotFromRaw({ name: "LCN Runtime" }, rawData); + const devices = LcnMapper.toDevices(mappedSnapshot); + const entities = LcnMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lcn"); + expect(devices[0].manufacturer).toEqual("LCN"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lcn" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('exposes LCN runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LcnIntegration(); + const alias = new HomeAssistantLcnIntegration(); + expect(alias instanceof LcnIntegration).toBeTrue(); + expect(alias.domain).toEqual("lcn"); + expect(integration.status).toEqual("control-runtime"); + expect(lcnProfile.metadata.configFlow).toEqual(true); + expect(lcnProfile.metadata.requirements).toEqual([ + "pypck==0.9.11", + "lcn-frontend==0.2.7", + ]); + + const runtime = await integration.setup({ name: "LCN Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lcn", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lcn", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILcnSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LCN Device"); + + const command = await runtime.callService!({ domain: "lcn", service: lcnProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +tap.test('reads native LCN PCHK status over TCP', async () => { + const server = await startPchkTestServer(); + try { + const client = new LcnClient({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000, name: 'LCN PCHK Test' }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.device.model).toEqual('LCN-PCHK'); + expect(snapshot.entities.some((entityArg) => entityArg.id === 'pchk_connection' && entityArg.state === true)).toBeTrue(); + expect(server.received.includes('lcn')).toBeTrue(); + expect(server.received.includes('secret')).toBeTrue(); + expect(server.received.includes('!CHD')).toBeTrue(); + expect(server.received.includes('!OM1P')).toBeTrue(); + } finally { + await server.close(); + } +}); + +tap.test('sends native addressed LCN PCK command over TCP', async () => { + const server = await startPchkTestServer(); + try { + const client = new LcnClient({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000, postCommandWaitMs: 1 }); + const result = await client.execute({ domain: 'lcn', service: 'pck', target: {}, data: { pck: 'A1DI050000', segmentId: 0, moduleId: 10 } }); + + await waitForReceived(server, '>M000010.A1DI050000'); + expect(result.success).toBeTrue(); + expect(server.received.includes('>M000010.A1DI050000')).toBeTrue(); + } finally { + await server.close(); + } +}); + +tap.test('uses native LCN client through integration runtime for host configs', async () => { + const server = await startPchkTestServer(); + const runtime = await new LcnIntegration().setup({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000 }, {}); + try { + const status = await runtime.callService!({ domain: 'lcn', service: 'status', target: {} }); + const snapshot = status.data as ILcnSnapshot; + + expect(status.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + } finally { + await runtime.destroy(); + await server.close(); + } +}); + +interface IPchkTestServer { + port: number; + received: string[]; + close(): Promise; +} + +async function startPchkTestServer(): Promise { + const received: string[] = []; + const sockets = new Set(); + const server = plugins.net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + socket.write('Username:\n'); + let buffer = ''; + socket.on('data', (chunkArg) => { + buffer += chunkArg.toString('utf8'); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ''); + if (!line) { + continue; + } + received.push(line); + if (line === 'lcn') { + socket.write('Password:\n'); + } else if (line === 'secret') { + socket.write('OK\n$io:#LCN:connected\n'); + } else if (line === '!CHD') { + socket.write('(dec-mode)\n'); + } + } + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('LCN test server did not bind to a TCP port.'); + } + + return { + port: address.port, + received, + close: async () => { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function waitForReceived(serverArg: IPchkTestServer, lineArg: string): Promise { + for (let index = 0; index < 20; index++) { + if (serverArg.received.includes(lineArg)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +export default tap.start(); diff --git a/test/ld2410_ble/test.ld2410_ble.node.ts b/test/ld2410_ble/test.ld2410_ble.node.ts new file mode 100644 index 0000000..a77025c --- /dev/null +++ b/test/ld2410_ble/test.ld2410_ble.node.ts @@ -0,0 +1,82 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLd2410BleIntegration, Ld2410BleClient, Ld2410BleConfigFlow, Ld2410BleIntegration, Ld2410BleMapper, createLd2410BleDiscoveryDescriptor, ld2410BleProfile, type ILd2410BleSnapshot, type TLd2410BleRawData } from '../../ts/integrations/ld2410_ble/index.js'; + +const rawData: TLd2410BleRawData = { + device: { + id: 'ld2410_ble-device-1', + name: "LD2410 BLE Device", + manufacturer: "LD2410 BLE", + model: "LD2410 BLE local integration", + serialNumber: 'ld2410_ble-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "ld2410_ble" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LD2410 BLE candidates and creates config flow output', async () => { + const descriptor = createLd2410BleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ld2410_ble-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'ld2410_ble-device-1', name: "LD2410 BLE Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("ld2410_ble"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new Ld2410BleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('ld2410_ble-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LD2410 BLE raw snapshots to runtime devices and entities', async () => { + const client = new Ld2410BleClient({ name: "LD2410 BLE Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = Ld2410BleMapper.toSnapshotFromRaw({ name: "LD2410 BLE Runtime" }, rawData); + const devices = Ld2410BleMapper.toDevices(mappedSnapshot); + const entities = Ld2410BleMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("ld2410_ble"); + expect(devices[0].manufacturer).toEqual("LD2410 BLE"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "ld2410_ble" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('exposes LD2410 BLE runtime, HA alias, and unsupported control without executor', async () => { + const integration = new Ld2410BleIntegration(); + const alias = new HomeAssistantLd2410BleIntegration(); + expect(alias instanceof Ld2410BleIntegration).toBeTrue(); + expect(alias.domain).toEqual("ld2410_ble"); + expect(integration.status).toEqual("read-only-runtime"); + expect(ld2410BleProfile.metadata.configFlow).toEqual(true); + expect(ld2410BleProfile.metadata.requirements).toEqual([ + "bluetooth-data-tools==1.28.4", + "ld2410-ble==0.1.1", + ]); + const localApi = ld2410BleProfile.metadata.localApi as { status: string; explicitUnsupported: string[] }; + expect(localApi.status).toContain('unavailable BLE stack'); + expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('bleak_retry_connector') && itemArg.includes('ld2410-ble'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LD2410 BLE Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "ld2410_ble", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "ld2410_ble", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILd2410BleSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LD2410 BLE Device"); + + const command = await runtime.callService!({ domain: "ld2410_ble", service: ld2410BleProfile.controlServices?.[0] || '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/leaone/test.leaone.node.ts b/test/leaone/test.leaone.node.ts new file mode 100644 index 0000000..bf7b0cc --- /dev/null +++ b/test/leaone/test.leaone.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LeaoneClient, LeaoneConfigFlow, LeaoneIntegration, LeaoneMapper, createLeaoneDiscoveryDescriptor, leaoneProfile, type ILeaoneSnapshot, type TLeaoneRawData } from '../../ts/integrations/leaone/index.js'; + +const rawData: TLeaoneRawData = { + device: { + id: 'leaone-device-1', + name: "LeaOne Device", + manufacturer: "LeaOne", + model: "LeaOne local integration", + serialNumber: 'leaone-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "leaone" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LeaOne candidates and creates config flow output', async () => { + const descriptor = createLeaoneDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'leaone-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'leaone-device-1', name: "LeaOne Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("leaone"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LeaoneConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('leaone-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LeaOne raw snapshots to runtime devices and entities', async () => { + const client = new LeaoneClient({ name: "LeaOne Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LeaoneMapper.toSnapshotFromRaw({ name: "LeaOne Runtime" }, rawData); + const devices = LeaoneMapper.toDevices(mappedSnapshot); + const entities = LeaoneMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("leaone"); + expect(devices[0].manufacturer).toEqual("LeaOne"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "leaone" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes LeaOne runtime and unsupported control without executor', async () => { + const integration = new LeaoneIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(leaoneProfile.metadata.configFlow).toEqual(true); + expect(leaoneProfile.metadata.requirements).toEqual([ + "leaone-ble==0.3.0", + ]); + expect((leaoneProfile.metadata.localApi as { status: string }).status).toContain('leaone-ble'); + expect(((leaoneProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).some((itemArg) => itemArg.includes('BLE scanning'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LeaOne Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "leaone", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "leaone", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILeaoneSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LeaOne Device"); + + const command = await runtime.callService!({ domain: "leaone", service: leaoneProfile.controlServices?.[0] || '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/led_ble/test.led_ble.node.ts b/test/led_ble/test.led_ble.node.ts new file mode 100644 index 0000000..67175d7 --- /dev/null +++ b/test/led_ble/test.led_ble.node.ts @@ -0,0 +1,78 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LedBleClient, LedBleConfigFlow, LedBleIntegration, LedBleMapper, createLedBleDiscoveryDescriptor, ledBleProfile, type ILedBleSnapshot, type TLedBleRawData } from '../../ts/integrations/led_ble/index.js'; + +const rawData: TLedBleRawData = { + device: { + id: 'led_ble-device-1', + name: "LED BLE Device", + manufacturer: "LED BLE", + model: "LED BLE local integration", + serialNumber: 'led_ble-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "light", state: true, attributes: { domain: "led_ble" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LED BLE candidates and creates config flow output', async () => { + const descriptor = createLedBleDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'led_ble-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'led_ble-device-1', name: "LED BLE Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("led_ble"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LedBleConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('led_ble-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LED BLE raw snapshots to runtime devices and entities', async () => { + const client = new LedBleClient({ name: "LED BLE Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LedBleMapper.toSnapshotFromRaw({ name: "LED BLE Runtime" }, rawData); + const devices = LedBleMapper.toDevices(mappedSnapshot); + const entities = LedBleMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("led_ble"); + expect(devices[0].manufacturer).toEqual("LED BLE"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "led_ble" && entityArg.platform === "light")).toBeTrue(); +}); + +tap.test('exposes LED BLE runtime and unsupported control without executor', async () => { + const integration = new LedBleIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(ledBleProfile.metadata.configFlow).toEqual(true); + expect(ledBleProfile.metadata.requirements).toEqual([ + "bluetooth-data-tools==1.28.4", + "led-ble==1.1.8", + ]); + expect((ledBleProfile.metadata.localApi as { status: string }).status).toContain('led-ble'); + expect(((ledBleProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).some((itemArg) => itemArg.includes('GATT'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LED BLE Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "led_ble", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "led_ble", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILedBleSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LED BLE Device"); + + const command = await runtime.callService!({ domain: "led_ble", service: ledBleProfile.controlServices?.[0] || '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/lektrico/test.lektrico.node.ts b/test/lektrico/test.lektrico.node.ts new file mode 100644 index 0000000..683cc02 --- /dev/null +++ b/test/lektrico/test.lektrico.node.ts @@ -0,0 +1,188 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LektricoClient, LektricoConfigFlow, LektricoIntegration, LektricoMapper, createLektricoDiscoveryDescriptor, lektricoProfile, type ILektricoSnapshot, type TLektricoRawData } from '../../ts/integrations/lektrico/index.js'; + +const readBody = async (requestArg: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(valueArg)); +}; + +const rawData: TLektricoRawData = { + device: { + id: 'lektrico-device-1', + name: "Lektrico Charging Station Device", + manufacturer: "Lektrico Charging Station", + model: "Lektrico Charging Station local integration", + serialNumber: 'lektrico-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lektrico" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Lektrico Charging Station candidates and creates config flow output', async () => { + const descriptor = createLektricoDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lektrico-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lektrico-device-1', name: "Lektrico Charging Station Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lektrico"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LektricoConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lektrico-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Lektrico Charging Station raw snapshots to runtime devices and entities', async () => { + const client = new LektricoClient({ name: "Lektrico Charging Station Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LektricoMapper.toSnapshotFromRaw({ name: "Lektrico Charging Station Runtime" }, rawData); + const devices = LektricoMapper.toDevices(mappedSnapshot); + const entities = LektricoMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lektrico"); + expect(devices[0].manufacturer).toEqual("Lektrico Charging Station"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lektrico" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads Lektrico charger snapshots over local HTTP JSON-RPC and writes mapped commands', async () => { + const requests: Array<{ method?: string; url?: string; body?: Record }> = []; + const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => { + void (async () => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + let body: Record | undefined; + if (requestArg.method === 'POST') { + body = JSON.parse(await readBody(requestArg)) as Record; + } + requests.push({ method: requestArg.method, url: url.pathname, body }); + + if (requestArg.method === 'GET' && url.pathname === '/rpc/Device_id.Get') { + json(responseArg, { device_id: '3p22k_500006' }); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/rpc/charger_config.get') { + json(responseArg, { serial_number: 500006, board_revision: 'E' }); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/rpc/charger_info.get') { + json(responseArg, { + currents: [1, 2, 3], + voltages: [230, 231, 232], + fw_version: '1.45', + extended_charger_state: 'C', + session_energy: 1200, + charging_time: 60, + instant_power: 4200, + temperature: 39.8, + total_charged_energy: 18, + has_active_errors: false, + state_machine_e_activated: false, + user_current: 32, + current_limit_reason: 3, + }); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/rpc/app_config.get') { + json(responseArg, { headless: false, install_current: 32, led_max_brightness: 100 }); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/rpc/dynamic_current.get') { + json(responseArg, { dynamic_current: 16, relay_mode: 3 }); + return; + } + if (requestArg.method === 'POST' && url.pathname === '/rpc') { + json(responseArg, { id: body?.id, src: '3p22k_500006', dst: 'HASS', result: true }); + return; + } + + json(responseArg, { error: 'not found' }, 404); + })().catch((errorArg) => { + responseArg.statusCode = 500; + responseArg.end(String(errorArg)); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const url = `http://127.0.0.1:${port}`; + const client = new LektricoClient({ url, timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LektricoIntegration().setup({ url, timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + const dynamicLimit = entities.find((entityArg) => entityArg.attributes?.key === 'dynamic_limit')!; + const authentication = entities.find((entityArg) => entityArg.attributes?.key === 'authentication')!; + const forceSinglePhase = entities.find((entityArg) => entityArg.attributes?.key === 'force_single_phase')!; + const chargeStart = entities.find((entityArg) => entityArg.attributes?.key === 'charge_start')!; + + const dynamicResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: dynamicLimit.id }, data: { value: 20 } }); + const authResult = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: authentication.id } }); + const forceResult = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: forceSinglePhase.id } }); + const chargeResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: chargeStart.id } }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('500006'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'state')?.state).toEqual('charging'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.state).toEqual(4200); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'dynamic_limit')?.state).toEqual(16); + expect(dynamicResult.success).toBeTrue(); + expect(authResult.success).toBeTrue(); + expect(forceResult.success).toBeTrue(); + expect(chargeResult.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/rpc/Device_id.Get')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.method === 'dynamic_current.set' && (requestArg.body.params as Record).dynamic_current === 20)).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.method === 'app_config.set' && (requestArg.body.params as Record).config_key === 'headless' && (requestArg.body.params as Record).config_value === true)).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.method === 'dynamic_current.Set' && (requestArg.body.params as Record).relay_mode === 1)).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.method === 'charge.start')).toBeTrue(); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Lektrico Charging Station runtime and unsupported control without executor', async () => { + const integration = new LektricoIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(lektricoProfile.metadata.configFlow).toEqual(true); + expect(lektricoProfile.metadata.requirements).toEqual([ + "lektricowifi==0.1", + ]); + expect((lektricoProfile.metadata.localApi as { status: string }).status).toContain('Native local HTTP JSON-RPC is implemented'); + + const runtime = await integration.setup({ name: "Lektrico Charging Station Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lektrico", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lektrico", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILektricoSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Lektrico Charging Station Device"); + + const command = await runtime.callService!({ domain: "lektrico", service: lektricoProfile.controlServices?.[0] || '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/lg_netcast/test.lg_netcast.node.ts b/test/lg_netcast/test.lg_netcast.node.ts new file mode 100644 index 0000000..dbb96b6 --- /dev/null +++ b/test/lg_netcast/test.lg_netcast.node.ts @@ -0,0 +1,124 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLgNetcastIntegration, LgNetcastClient, LgNetcastConfigFlow, LgNetcastIntegration, LgNetcastMapper, createLgNetcastDiscoveryDescriptor, lgNetcastProfile, type ILgNetcastSnapshot, type TLgNetcastRawData } from '../../ts/integrations/lg_netcast/index.js'; + +const rawData: TLgNetcastRawData = { + device: { + id: 'lg_netcast-device-1', + name: "LG Netcast Device", + manufacturer: "LG Netcast", + model: "LG Netcast local integration", + serialNumber: 'lg_netcast-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "lg_netcast" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LG Netcast candidates and creates config flow output', async () => { + const descriptor = createLgNetcastDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lg_netcast-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lg_netcast-device-1', name: "LG Netcast Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lg_netcast"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LgNetcastConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lg_netcast-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LG Netcast raw snapshots to runtime devices and entities', async () => { + const client = new LgNetcastClient({ name: "LG Netcast Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LgNetcastMapper.toSnapshotFromRaw({ name: "LG Netcast Runtime" }, rawData); + const devices = LgNetcastMapper.toDevices(mappedSnapshot); + const entities = LgNetcastMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lg_netcast"); + expect(devices[0].manufacturer).toEqual("LG Netcast"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lg_netcast" && entityArg.platform === "button")).toBeTrue(); +}); + +tap.test('polls and controls LG Netcast through native ROAP HTTP', async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; method?: string; body?: string }> = []; + + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const url = String(inputArg); + const body = typeof initArg?.body === 'string' ? initArg.body : undefined; + requests.push({ url, method: initArg?.method, body }); + + if (url.includes('/data?target=rootservice.xml')) { + return new Response('netcast-uuid47LM760SLiving TV'); + } + if (url.includes('/data?target=volume_info')) { + return new Response('22false'); + } + if (url.includes('/data?target=cur_channel')) { + return new Response('7HDMI 1Movie'); + } + if (url.endsWith('/auth')) { + return new Response('session-1'); + } + if (url.endsWith('/command')) { + return new Response(''); + } + return new Response('not found', { status: 404 }); + }) as typeof globalThis.fetch; + + try { + const client = new LgNetcastClient({ host: '192.0.2.10', accessToken: '123456' }); + const snapshot = await client.getSnapshot(true); + const entity = snapshot.entities.find((entityArg) => entityArg.id === 'media_player'); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.serialNumber).toEqual('netcast-uuid'); + expect(entity?.attributes?.volumeLevel).toEqual(0.22); + expect(entity?.attributes?.channelName).toEqual('HDMI 1'); + + const result = await client.execute({ domain: 'media_player', service: 'media_pause', target: {} }); + expect(result.success).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.includes('AuthReq123456'))).toBeTrue(); + expect(requests.some((requestArg) => requestArg.body?.includes('HandleKeyInput34'))).toBeTrue(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes LG Netcast runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LgNetcastIntegration(); + const alias = new HomeAssistantLgNetcastIntegration(); + expect(alias instanceof LgNetcastIntegration).toBeTrue(); + expect(alias.domain).toEqual("lg_netcast"); + expect(integration.status).toEqual("control-runtime"); + expect(lgNetcastProfile.metadata.configFlow).toEqual(true); + expect(lgNetcastProfile.metadata.requirements).toEqual([ + "pylgnetcast==0.3.9", + ]); + + const runtime = await integration.setup({ name: "LG Netcast Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lg_netcast", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lg_netcast", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILgNetcastSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LG Netcast Device"); + + const command = await runtime.callService!({ domain: "lg_netcast", service: lgNetcastProfile.controlServices?.[0] || '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/lg_soundbar/test.lg_soundbar.node.ts b/test/lg_soundbar/test.lg_soundbar.node.ts new file mode 100644 index 0000000..211d921 --- /dev/null +++ b/test/lg_soundbar/test.lg_soundbar.node.ts @@ -0,0 +1,203 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../../ts/plugins.js'; +import { HomeAssistantLgSoundbarIntegration, LgSoundbarClient, LgSoundbarConfigFlow, LgSoundbarIntegration, LgSoundbarMapper, createLgSoundbarDiscoveryDescriptor, lgSoundbarProfile, type ILgSoundbarSnapshot, type TLgSoundbarRawData } from '../../ts/integrations/lg_soundbar/index.js'; + +const rawData: TLgSoundbarRawData = { + device: { + id: 'lg_soundbar-device-1', + name: "LG Soundbars Device", + manufacturer: "LG Soundbars", + model: "LG Soundbars local integration", + serialNumber: 'lg_soundbar-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "media_player", state: true, attributes: { domain: "lg_soundbar" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LG Soundbars candidates and creates config flow output', async () => { + const descriptor = createLgSoundbarDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lg_soundbar-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lg_soundbar-device-1', name: "LG Soundbars Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lg_soundbar"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LgSoundbarConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lg_soundbar-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LG Soundbars raw snapshots to runtime devices and entities', async () => { + const client = new LgSoundbarClient({ name: "LG Soundbars Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LgSoundbarMapper.toSnapshotFromRaw({ name: "LG Soundbars Runtime" }, rawData); + const devices = LgSoundbarMapper.toDevices(mappedSnapshot); + const entities = LgSoundbarMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lg_soundbar"); + expect(devices[0].manufacturer).toEqual("LG Soundbars"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lg_soundbar" && entityArg.platform === "media_player")).toBeTrue(); +}); + +tap.test('polls and controls LG Soundbars through native encrypted TCP', async () => { + const server = await startLgSoundbarTestServer(); + try { + const client = new LgSoundbarClient({ host: '127.0.0.1', port: server.port, timeoutMs: 500 }); + const snapshot = await client.getSnapshot(true); + const entity = snapshot.entities.find((entityArg) => entityArg.id === 'media_player'); + + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.device.serialNumber).toEqual('soundbar-uuid'); + expect(entity?.state).toEqual('playing'); + expect(entity?.attributes?.volumeLevel).toEqual(0.5); + expect(entity?.attributes?.source).toEqual('Bluetooth'); + expect(entity?.attributes?.soundMode).toEqual('Bass Blast'); + + const result = await client.execute({ domain: 'media_player', service: 'media_pause', target: {} }); + expect(result.success).toBeTrue(); + expect(server.received.some((packetArg) => packetArg.cmd === 'set' && packetArg.msg === 'PLAY_INFO' && (packetArg.data as Record).i_play_ctrl === 1)).toBeTrue(); + } finally { + await server.close(); + } +}); + +tap.test('exposes LG Soundbars runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LgSoundbarIntegration(); + const alias = new HomeAssistantLgSoundbarIntegration(); + expect(alias instanceof LgSoundbarIntegration).toBeTrue(); + expect(alias.domain).toEqual("lg_soundbar"); + expect(integration.status).toEqual("control-runtime"); + expect(lgSoundbarProfile.metadata.configFlow).toEqual(true); + expect(lgSoundbarProfile.metadata.requirements).toEqual([ + "temescal==0.5", + ]); + + const runtime = await integration.setup({ name: "LG Soundbars Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lg_soundbar", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lg_soundbar", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILgSoundbarSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LG Soundbars Device"); + + const command = await runtime.callService!({ domain: "lg_soundbar", service: lgSoundbarProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +interface ILgSoundbarTestServer { + port: number; + received: Record[]; + close(): Promise; +} + +async function startLgSoundbarTestServer(): Promise { + const received: Record[] = []; + const sockets = new Set(); + const server = plugins.net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let buffer: Buffer = Buffer.alloc(0); + socket.on('data', (chunkArg) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)]); + const parsed = parseLgSoundbarFrames(buffer); + buffer = parsed.rest; + for (const packet of parsed.packets) { + received.push(packet); + const response = lgSoundbarResponseFor(packet); + if (response) { + socket.write(encryptLgSoundbarPacket(response)); + } + } + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('LG Soundbar test server did not bind to a TCP port.'); + } + + return { + port: address.port, + received, + close: async () => { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function lgSoundbarResponseFor(packetArg: Record): Record | undefined { + if (packetArg.cmd !== 'get') { + return undefined; + } + const responses: Record> = { + PRODUCT_INFO: { cmd: 'get', msg: 'PRODUCT_INFO', data: { s_model_name: 'SN11RG', s_uuid: 'soundbar-product-uuid' } }, + MAC_INFO_DEV: { cmd: 'get', msg: 'MAC_INFO_DEV', data: { s_uuid: 'soundbar-uuid', s_mac_addr: '00:11:22:33:44:55' } }, + EQ_VIEW_INFO: { cmd: 'get', msg: 'EQ_VIEW_INFO', data: { i_curr_eq: 14, ai_eq_list: [0, 14] } }, + SPK_LIST_VIEW_INFO: { cmd: 'get', msg: 'SPK_LIST_VIEW_INFO', data: { i_vol: 20, i_vol_min: 0, i_vol_max: 40, b_mute: false, i_curr_func: 1, b_powerstatus: true, s_user_name: 'Living Soundbar' } }, + FUNC_VIEW_INFO: { cmd: 'get', msg: 'FUNC_VIEW_INFO', data: { i_curr_func: 1, ai_func_list: [0, 1, 4] } }, + SETTING_VIEW_INFO: { cmd: 'get', msg: 'SETTING_VIEW_INFO', data: { i_curr_eq: 14, s_user_name: 'Living Soundbar' } }, + PLAY_INFO: { cmd: 'get', msg: 'PLAY_INFO', data: { i_stream_type: 1, i_play_ctrl: 0, b_support_play_ctrl: true, s_artist: 'Artist', s_title: 'Track' } }, + }; + return responses[String(packetArg.msg)]; +} + +const lgSoundbarKey = Buffer.from('T^&*J%^7tr~4^%^&I(o%^!jIJ__+a0 k', 'utf8'); +const lgSoundbarIv = Buffer.from("'%^Ur7gy$~t+f)%@", 'utf8'); + +function encryptLgSoundbarPacket(packetArg: Record): Buffer { + const cipher = plugins.crypto.createCipheriv('aes-256-cbc', lgSoundbarKey, lgSoundbarIv); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(packetArg), 'utf8'), cipher.final()]); + const header = Buffer.alloc(5); + header[0] = 0x10; + header.writeUInt32BE(encrypted.length, 1); + return Buffer.concat([header, encrypted]); +} + +function decryptLgSoundbarPacket(dataArg: Buffer): Record { + const decipher = plugins.crypto.createDecipheriv('aes-256-cbc', lgSoundbarKey, lgSoundbarIv); + return JSON.parse(Buffer.concat([decipher.update(dataArg), decipher.final()]).toString('utf8')) as Record; +} + +function parseLgSoundbarFrames(bufferArg: Buffer): { packets: Record[]; rest: Buffer } { + const packets: Record[] = []; + let offset = 0; + while (offset < bufferArg.length && bufferArg[offset] !== 0x10) { + offset++; + } + while (bufferArg.length - offset >= 5) { + const length = bufferArg.readUInt32BE(offset + 1); + if (bufferArg.length - offset - 5 < length) { + break; + } + packets.push(decryptLgSoundbarPacket(bufferArg.subarray(offset + 5, offset + 5 + length))); + offset += 5 + length; + } + return { packets, rest: bufferArg.subarray(offset) }; +} + +export default tap.start(); diff --git a/test/libre_hardware_monitor/test.libre_hardware_monitor.node.ts b/test/libre_hardware_monitor/test.libre_hardware_monitor.node.ts new file mode 100644 index 0000000..9955059 --- /dev/null +++ b/test/libre_hardware_monitor/test.libre_hardware_monitor.node.ts @@ -0,0 +1,159 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLibreHardwareMonitorIntegration, LibreHardwareMonitorClient, LibreHardwareMonitorConfigFlow, LibreHardwareMonitorIntegration, LibreHardwareMonitorMapper, createLibreHardwareMonitorDiscoveryDescriptor, libreHardwareMonitorProfile, type ILibreHardwareMonitorSnapshot, type TLibreHardwareMonitorRawData } from '../../ts/integrations/libre_hardware_monitor/index.js'; + +const rawData: TLibreHardwareMonitorRawData = { + device: { + id: 'libre_hardware_monitor-device-1', + name: "Libre Hardware Monitor Device", + manufacturer: "Libre Hardware Monitor", + model: "Libre Hardware Monitor local integration", + serialNumber: 'libre_hardware_monitor-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "libre_hardware_monitor" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Libre Hardware Monitor candidates and creates config flow output', async () => { + const descriptor = createLibreHardwareMonitorDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'libre_hardware_monitor-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'libre_hardware_monitor-device-1', name: "Libre Hardware Monitor Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("libre_hardware_monitor"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LibreHardwareMonitorConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('libre_hardware_monitor-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Libre Hardware Monitor raw snapshots to runtime devices and entities', async () => { + const client = new LibreHardwareMonitorClient({ name: "Libre Hardware Monitor Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LibreHardwareMonitorMapper.toSnapshotFromRaw({ name: "Libre Hardware Monitor Runtime" }, rawData); + const devices = LibreHardwareMonitorMapper.toDevices(mappedSnapshot); + const entities = LibreHardwareMonitorMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("libre_hardware_monitor"); + expect(devices[0].manufacturer).toEqual("Libre Hardware Monitor"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "libre_hardware_monitor" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('polls Libre Hardware Monitor /data.json through native HTTP', async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; authorization?: string }> = []; + + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const headers = initArg?.headers as Record | undefined; + requests.push({ url: String(inputArg), authorization: headers?.authorization }); + return new Response(JSON.stringify(libreHardwareMonitorFixture), { headers: { 'content-type': 'application/json' } }); + }) as typeof globalThis.fetch; + + try { + const client = new LibreHardwareMonitorClient({ host: '127.0.0.1', port: 8085, username: 'user', password: 'pass' }); + const snapshot = await client.getSnapshot(true); + const cpuEntity = snapshot.entities.find((entityArg) => entityArg.id === 'amdcpu-0-temperature-0'); + + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.name).toEqual('COMPUTER'); + expect(snapshot.entities.length).toEqual(2); + expect(cpuEntity?.name).toEqual('CPU Package Temperature'); + expect(cpuEntity?.state).toEqual('40.0'); + expect(cpuEntity?.unit).toEqual('C'); + expect(cpuEntity?.attributes?.min_value).toEqual('38.0'); + expect(requests[0].url).toEqual('http://127.0.0.1:8085/data.json'); + expect(requests[0].authorization).toEqual(`Basic ${Buffer.from('user:pass').toString('base64')}`); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Libre Hardware Monitor runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LibreHardwareMonitorIntegration(); + const alias = new HomeAssistantLibreHardwareMonitorIntegration(); + expect(alias instanceof LibreHardwareMonitorIntegration).toBeTrue(); + expect(alias.domain).toEqual("libre_hardware_monitor"); + expect(integration.status).toEqual("read-only-runtime"); + expect(libreHardwareMonitorProfile.metadata.configFlow).toEqual(true); + expect(libreHardwareMonitorProfile.metadata.requirements).toEqual([ + "librehardwaremonitor-api==1.11.1", + ]); + + const runtime = await integration.setup({ name: "Libre Hardware Monitor Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "libre_hardware_monitor", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "libre_hardware_monitor", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILibreHardwareMonitorSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Libre Hardware Monitor Device"); + + const command = await runtime.callService!({ domain: "libre_hardware_monitor", service: libreHardwareMonitorProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +const libreHardwareMonitorFixture = { + Version: '0.9.7', + Text: 'Sensor', + Children: [ + { + Text: 'COMPUTER', + ImageURL: 'images_icon/computer.png', + Children: [ + { + Text: 'AMD Ryzen', + HardwareId: '/amdcpu/0', + ImageURL: 'images_icon/cpu.png', + Children: [ + { + Text: 'Temperatures', + ImageURL: 'images_icon/temperature.png', + Children: [ + { + Text: 'CPU Package', + Value: '40,0 C', + Min: '38,0 C', + Max: '70,0 C', + SensorId: '/amdcpu/0/temperature/0', + Type: 'Temperature', + ImageURL: 'images/transparent.png', + Children: [], + }, + ], + }, + { + Text: 'Load', + ImageURL: 'images_icon/load.png', + Children: [ + { + Text: 'CPU Total', + Value: '12,5 %', + Min: '0,0 %', + Max: '90,0 %', + SensorId: '/amdcpu/0/load/0', + Type: 'Load', + ImageURL: 'images/transparent.png', + Children: [], + }, + ], + }, + ], + }, + ], + }, + ], +}; + +export default tap.start(); diff --git a/test/lidarr/test.lidarr.node.ts b/test/lidarr/test.lidarr.node.ts new file mode 100644 index 0000000..d7f3106 --- /dev/null +++ b/test/lidarr/test.lidarr.node.ts @@ -0,0 +1,138 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLidarrIntegration, LidarrClient, LidarrConfigFlow, LidarrIntegration, LidarrMapper, createLidarrDiscoveryDescriptor, lidarrProfile, type ILidarrSnapshot, type TLidarrRawData } from '../../ts/integrations/lidarr/index.js'; + +const rawData: TLidarrRawData = { + device: { + id: 'lidarr-device-1', + name: "Lidarr Device", + manufacturer: "Lidarr", + model: "Lidarr local integration", + serialNumber: 'lidarr-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "lidarr" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Lidarr candidates and creates config flow output', async () => { + const descriptor = createLidarrDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lidarr-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lidarr-device-1', name: "Lidarr Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lidarr"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LidarrConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lidarr-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Lidarr raw snapshots to runtime devices and entities', async () => { + const client = new LidarrClient({ name: "Lidarr Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LidarrMapper.toSnapshotFromRaw({ name: "Lidarr Runtime" }, rawData); + const devices = LidarrMapper.toDevices(mappedSnapshot); + const entities = LidarrMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lidarr"); + expect(devices[0].manufacturer).toEqual("Lidarr"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lidarr" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('fetches Lidarr HTTP API snapshots with API key headers', async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; apiKey?: string }> = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => { + const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url; + const headers = new Headers(initArg?.headers); + requests.push({ url, apiKey: headers.get('x-api-key') || undefined }); + const parsed = new URL(url); + + if (parsed.pathname === '/api/v1/system/status') { + return jsonResponse({ appName: 'Lidarr', instanceName: 'Music Lidarr', version: '2.1.0', branch: 'main' }); + } + if (parsed.pathname === '/api/v1/rootfolder') { + return jsonResponse([{ path: '/srv/music', accessible: true, freeSpace: 10 * 1024 ** 3, totalSpace: 100 * 1024 ** 3 }]); + } + if (parsed.pathname === '/api/v1/queue') { + return jsonResponse({ totalRecords: 1, records: [{ title: 'Queued Album', trackedDownloadState: 'downloading' }] }); + } + if (parsed.pathname === '/api/v1/wanted/missing') { + return jsonResponse({ totalRecords: 2, records: [{ title: 'Missing Album', artist: { artistName: 'Missing Artist' } }] }); + } + if (parsed.pathname === '/api/v1/album') { + return jsonResponse([{ title: 'One' }, { title: 'Two' }, { title: 'Three' }]); + } + return jsonResponse({ error: 'not found' }, 404); + }) as typeof fetch; + + try { + const client = new LidarrClient({ host: '127.0.0.1', port: 8686, apiKey: 'lidarr-key', timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LidarrIntegration().setup({ host: '127.0.0.1', port: 8686, apiKey: 'lidarr-key', timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'version')?.state).toEqual('2.1.0'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'albums')?.state).toEqual(3); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'queue')?.state).toEqual(1); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'wanted')?.state).toEqual(2); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'disk_space_music')?.state).toEqual(10); + expect(entities.some((entityArg) => entityArg.id === 'sensor.music_lidarr_albums')).toBeTrue(); + expect(requests.every((requestArg) => requestArg.apiKey === 'lidarr-key')).toBeTrue(); + expect(requests.some((requestArg) => { + const url = new URL(requestArg.url); + return url.pathname === '/api/v1/queue' && url.searchParams.get('pageSize') === '20'; + })).toBeTrue(); + expect(requests.some((requestArg) => new URL(requestArg.url).pathname === '/api/v1/wanted/missing')).toBeTrue(); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes Lidarr runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LidarrIntegration(); + const alias = new HomeAssistantLidarrIntegration(); + expect(alias instanceof LidarrIntegration).toBeTrue(); + expect(alias.domain).toEqual("lidarr"); + expect(integration.status).toEqual("read-only-runtime"); + expect(lidarrProfile.metadata.configFlow).toEqual(true); + expect(lidarrProfile.metadata.requirements).toEqual([ + "aiopyarr==23.4.0", + ]); + + const runtime = await integration.setup({ name: "Lidarr Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lidarr", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lidarr", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILidarrSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Lidarr Device"); + + const command = await runtime.callService!({ domain: "lidarr", service: lidarrProfile.controlServices?.[0] || '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(); + +const jsonResponse = (valueArg: unknown, statusArg = 200): Response => { + return new Response(JSON.stringify(valueArg), { + status: statusArg, + headers: { 'content-type': 'application/json' }, + }); +}; diff --git a/test/lifx/test.lifx.node.ts b/test/lifx/test.lifx.node.ts new file mode 100644 index 0000000..be7f4f9 --- /dev/null +++ b/test/lifx/test.lifx.node.ts @@ -0,0 +1,83 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLifxIntegration, LifxClient, LifxConfigFlow, LifxIntegration, LifxMapper, createLifxDiscoveryDescriptor, lifxProfile, type ILifxSnapshot, type TLifxRawData } from '../../ts/integrations/lifx/index.js'; + +const rawData: TLifxRawData = { + device: { + id: 'lifx-device-1', + name: "LIFX Device", + manufacturer: "LIFX", + model: "LIFX local integration", + serialNumber: 'lifx-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lifx" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LIFX candidates and creates config flow output', async () => { + const descriptor = createLifxDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lifx-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lifx-device-1', name: "LIFX Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lifx"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LifxConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lifx-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LIFX raw snapshots to runtime devices and entities', async () => { + const client = new LifxClient({ name: "LIFX Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LifxMapper.toSnapshotFromRaw({ name: "LIFX Runtime" }, rawData); + const devices = LifxMapper.toDevices(mappedSnapshot); + const entities = LifxMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lifx"); + expect(devices[0].manufacturer).toEqual("LIFX"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lifx" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('exposes LIFX runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LifxIntegration(); + const alias = new HomeAssistantLifxIntegration(); + expect(alias instanceof LifxIntegration).toBeTrue(); + expect(alias.domain).toEqual("lifx"); + expect(integration.status).toEqual("control-runtime"); + expect(lifxProfile.metadata.configFlow).toEqual(true); + expect(lifxProfile.metadata.requirements).toEqual([ + "aiolifx==1.2.1", + "aiolifx-effects==0.3.2", + "aiolifx-themes==1.0.2", + ]); + const localApi = lifxProfile.metadata.localApi as { status?: string; explicitUnsupported?: string[] }; + expect(localApi.status || '').toContain('UDP binary protocol'); + expect(localApi.explicitUnsupported?.some((entryArg) => entryArg.includes('aiolifx UDP binary protocol'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LIFX Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lifx", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lifx", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILifxSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LIFX Device"); + + const command = await runtime.callService!({ domain: "lifx", service: lifxProfile.controlServices?.[0] || '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/linksys_smart/test.linksys_smart.node.ts b/test/linksys_smart/test.linksys_smart.node.ts new file mode 100644 index 0000000..31d31f7 --- /dev/null +++ b/test/linksys_smart/test.linksys_smart.node.ts @@ -0,0 +1,160 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLinksysSmartIntegration, LinksysSmartClient, LinksysSmartConfigFlow, LinksysSmartIntegration, LinksysSmartMapper, createLinksysSmartDiscoveryDescriptor, linksysSmartProfile, type ILinksysSmartSnapshot, type TLinksysSmartRawData } from '../../ts/integrations/linksys_smart/index.js'; + +const rawData: TLinksysSmartRawData = { + device: { + id: 'linksys_smart-device-1', + name: "Linksys Smart Wi-Fi Device", + manufacturer: "Linksys Smart Wi-Fi", + model: "Linksys Smart Wi-Fi local integration", + serialNumber: 'linksys_smart-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "linksys_smart" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Linksys Smart Wi-Fi candidates and creates config flow output', async () => { + const descriptor = createLinksysSmartDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'linksys_smart-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'linksys_smart-device-1', name: "Linksys Smart Wi-Fi Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("linksys_smart"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LinksysSmartConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('linksys_smart-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Linksys Smart Wi-Fi raw snapshots to runtime devices and entities', async () => { + const client = new LinksysSmartClient({ name: "Linksys Smart Wi-Fi Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LinksysSmartMapper.toSnapshotFromRaw({ name: "Linksys Smart Wi-Fi Runtime" }, rawData); + const devices = LinksysSmartMapper.toDevices(mappedSnapshot); + const entities = LinksysSmartMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("linksys_smart"); + expect(devices[0].manufacturer).toEqual("Linksys Smart Wi-Fi"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "linksys_smart" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('fetches Linksys Smart Wi-Fi JNAP connected device snapshots', async () => { + const calls: Array<{ url?: string; method?: string; action?: string; body: unknown }> = []; + const server = createServer(async (requestArg: IncomingMessage, responseArg: ServerResponse) => { + const body = await readJsonBody(requestArg); + calls.push({ url: requestArg.url, method: requestArg.method, action: requestArg.headers['x-jnap-action'] as string | undefined, body }); + if (requestArg.method !== 'POST' || requestArg.url !== '/JNAP/') { + responseArg.statusCode = 404; + responseArg.end('not found'); + return; + } + responseArg.statusCode = 200; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify({ + responses: [{ + output: { + devices: [ + { + deviceID: 'device-1', + friendlyName: 'Laptop fallback', + knownMACAddresses: ['AA:BB:CC:DD:EE:01'], + connections: [{ ipAddress: '192.168.1.10' }], + properties: [{ name: 'userDeviceName', value: 'Laptop' }], + }, + { + deviceID: 'device-2', + friendlyName: 'Phone', + knownMACAddresses: ['AA:BB:CC:DD:EE:02'], + connections: [], + properties: [], + }, + { + deviceID: 'device-3', + friendlyName: 'No MAC', + knownMACAddresses: [], + connections: [{ ipAddress: '192.168.1.11' }], + properties: [], + }, + { + deviceID: 'device-4', + friendlyName: 'Printer', + knownMACAddresses: ['AA:BB:CC:DD:EE:03'], + connections: [{ ipAddress: '192.168.1.12' }], + properties: [], + }, + ], + }, + }], + })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const config = { host: '127.0.0.1', port, timeoutMs: 1000, name: 'Linksys Router' }; + const client = new LinksysSmartClient(config); + const snapshot = await client.getSnapshot(true); + const runtime = await new LinksysSmartIntegration().setup(config, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'connected_devices')?.state).toEqual(2); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'presence_aa_bb_cc_dd_ee_01')?.name).toEqual('Laptop'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'presence_aa_bb_cc_dd_ee_03')?.name).toEqual('Printer'); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.linksys_router_presence_aa_bb_cc_dd_ee_01')).toBeTrue(); + expect(calls[0].action).toEqual('http://linksys.com/jnap/core/Transaction'); + expect(calls[0].body).toEqual([{ request: { sinceRevision: 0 }, action: 'http://linksys.com/jnap/devicelist/GetDevices' }]); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Linksys Smart Wi-Fi runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LinksysSmartIntegration(); + const alias = new HomeAssistantLinksysSmartIntegration(); + expect(alias instanceof LinksysSmartIntegration).toBeTrue(); + expect(alias.domain).toEqual("linksys_smart"); + expect(integration.status).toEqual("read-only-runtime"); + expect(linksysSmartProfile.metadata.configFlow).toEqual(false); + expect(linksysSmartProfile.metadata.requirements).toEqual([]); + + const runtime = await integration.setup({ name: "Linksys Smart Wi-Fi Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "linksys_smart", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "linksys_smart", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILinksysSmartSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Linksys Smart Wi-Fi Device"); + + const command = await runtime.callService!({ domain: "linksys_smart", service: linksysSmartProfile.controlServices?.[0] || '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(); + +const readJsonBody = async (requestArg: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text ? JSON.parse(text) as unknown : undefined; +}; diff --git a/test/linux_battery/test.linux_battery.node.ts b/test/linux_battery/test.linux_battery.node.ts new file mode 100644 index 0000000..4f47da2 --- /dev/null +++ b/test/linux_battery/test.linux_battery.node.ts @@ -0,0 +1,109 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../../ts/plugins.js'; +import { LinuxBatteryClient, LinuxBatteryConfigFlow, LinuxBatteryIntegration, LinuxBatteryMapper, createLinuxBatteryDiscoveryDescriptor, linuxBatteryProfile, type ILinuxBatterySnapshot, type TLinuxBatteryRawData } from '../../ts/integrations/linux_battery/index.js'; + +const rawData: TLinuxBatteryRawData = { + device: { + id: 'linux_battery-device-1', + name: "Linux Battery Device", + manufacturer: "Linux Battery", + model: "Linux Battery local integration", + serialNumber: 'linux_battery-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "linux_battery" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Linux Battery candidates and creates config flow output', async () => { + const descriptor = createLinuxBatteryDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'linux_battery-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'linux_battery-device-1', name: "Linux Battery Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("linux_battery"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LinuxBatteryConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('linux_battery-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Linux Battery raw snapshots to runtime devices and entities', async () => { + const client = new LinuxBatteryClient({ name: "Linux Battery Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LinuxBatteryMapper.toSnapshotFromRaw({ name: "Linux Battery Runtime" }, rawData); + const devices = LinuxBatteryMapper.toDevices(mappedSnapshot); + const entities = LinuxBatteryMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("linux_battery"); + expect(devices[0].manufacturer).toEqual("Linux Battery"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "linux_battery" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('polls Linux Battery capacity from local power_supply sysfs files', async () => { + const rootPath = `/tmp/opencode/linux-battery-test-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const batteryPath = plugins.path.join(rootPath, 'BAT1'); + await plugins.fs.mkdir(batteryPath, { recursive: true }); + await Promise.all(Object.entries({ + capacity: '87\n', + status: 'Discharging\n', + manufacturer: 'Framework\n', + model_name: 'FRANDBA\n', + serial_number: 'BAT-SERIAL-1\n', + energy_now: '39120000\n', + energy_full: '55000000\n', + voltage_now: '15432000\n', + }).map(([fileNameArg, valueArg]) => plugins.fs.writeFile(plugins.path.join(batteryPath, fileNameArg), valueArg))); + + try { + const client = new LinuxBatteryClient({ name: 'Laptop Battery', sysfsPath: rootPath, battery: 1 }); + const snapshot = await client.getSnapshot(); + const refresh = await client.refresh(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.manufacturer).toEqual('Framework'); + expect(snapshot.device.serialNumber).toEqual('BAT-SERIAL-1'); + expect(snapshot.entities[0].state).toEqual(87); + expect(snapshot.entities[0].attributes?.status).toEqual('Discharging'); + expect(snapshot.entities[0].attributes?.path).toEqual(batteryPath); + expect(refresh.success).toBeTrue(); + } finally { + await plugins.fs.rm(rootPath, { recursive: true, force: true }); + } +}); + +tap.test('exposes Linux Battery runtime and unsupported control without executor', async () => { + const integration = new LinuxBatteryIntegration(); + expect(integration.domain).toEqual("linux_battery"); + expect(integration.status).toEqual("read-only-runtime"); + expect(linuxBatteryProfile.metadata.configFlow).toEqual(false); + expect(linuxBatteryProfile.metadata.requirements).toEqual([ + "batinfo==0.4.2", + ]); + + const runtime = await integration.setup({ name: "Linux Battery Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "linux_battery", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "linux_battery", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILinuxBatterySnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Linux Battery Device"); + + const command = await runtime.callService!({ domain: "linux_battery", service: linuxBatteryProfile.controlServices?.[0] || '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/litejet/test.litejet.node.ts b/test/litejet/test.litejet.node.ts new file mode 100644 index 0000000..d3db5d5 --- /dev/null +++ b/test/litejet/test.litejet.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LitejetClient, LitejetConfigFlow, LitejetIntegration, LitejetMapper, createLitejetDiscoveryDescriptor, litejetProfile, type ILitejetSnapshot, type TLitejetRawData } from '../../ts/integrations/litejet/index.js'; + +const rawData: TLitejetRawData = { + device: { + id: 'litejet-device-1', + name: "LiteJet Device", + manufacturer: "LiteJet", + model: "LiteJet local integration", + serialNumber: 'litejet-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "litejet" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LiteJet candidates and creates config flow output', async () => { + const descriptor = createLitejetDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'litejet-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'litejet-device-1', name: "LiteJet Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("litejet"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LitejetConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('litejet-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LiteJet raw snapshots to runtime devices and entities', async () => { + const client = new LitejetClient({ name: "LiteJet Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LitejetMapper.toSnapshotFromRaw({ name: "LiteJet Runtime" }, rawData); + const devices = LitejetMapper.toDevices(mappedSnapshot); + const entities = LitejetMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("litejet"); + expect(devices[0].manufacturer).toEqual("LiteJet"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "litejet" && entityArg.platform === "button")).toBeTrue(); +}); + +tap.test('exposes LiteJet runtime and unsupported control without executor', async () => { + const integration = new LitejetIntegration(); + expect(integration.domain).toEqual("litejet"); + expect(integration.status).toEqual("control-runtime"); + expect(litejetProfile.metadata.configFlow).toEqual(true); + expect(litejetProfile.metadata.requirements).toEqual([ + "pylitejet==0.6.3", + ]); + expect((litejetProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((itemArg) => itemArg.includes('RS232') && itemArg.includes('no serial-port transport dependency'))).toBeTrue(); + + const runtime = await integration.setup({ name: "LiteJet Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "litejet", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "litejet", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILitejetSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LiteJet Device"); + + const command = await runtime.callService!({ domain: "litejet", service: litejetProfile.controlServices?.[0] || '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/livisi/test.livisi.node.ts b/test/livisi/test.livisi.node.ts new file mode 100644 index 0000000..75a436f --- /dev/null +++ b/test/livisi/test.livisi.node.ts @@ -0,0 +1,205 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LivisiClient, LivisiConfigFlow, LivisiIntegration, LivisiMapper, createLivisiDiscoveryDescriptor, livisiProfile, type ILivisiSnapshot, type TLivisiRawData } from '../../ts/integrations/livisi/index.js'; + +const rawData: TLivisiRawData = { + device: { + id: 'livisi-device-1', + name: "LIVISI Smart Home Device", + manufacturer: "LIVISI Smart Home", + model: "LIVISI Smart Home local integration", + serialNumber: 'livisi-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "livisi" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LIVISI Smart Home candidates and creates config flow output', async () => { + const descriptor = createLivisiDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'livisi-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'livisi-device-1', name: "LIVISI Smart Home Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("livisi"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LivisiConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('livisi-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LIVISI Smart Home raw snapshots to runtime devices and entities', async () => { + const client = new LivisiClient({ name: "LIVISI Smart Home Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LivisiMapper.toSnapshotFromRaw({ name: "LIVISI Smart Home Runtime" }, rawData); + const devices = LivisiMapper.toDevices(mappedSnapshot); + const entities = LivisiMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("livisi"); + expect(devices[0].manufacturer).toEqual("LIVISI Smart Home"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "livisi" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('polls LIVISI SHC over local HTTP and sends action commands only after auth', async () => { + const requests: Array<{ method?: string; url?: string; auth?: string; body?: unknown }> = []; + let actionPayload: Record | undefined; + const server = createServer((requestArg, responseArg) => { + void handleLivisiRequest(requestArg, responseArg, requests, (payloadArg) => { + actionPayload = payloadArg; + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const runtime = await new LivisiIntegration().setup({ host: '127.0.0.1', port, password: 'secret', timeoutMs: 1000 }, {}); + const entities = await runtime.entities(); + const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: {}, data: { capabilityId: 'cap-switch' } }); + + expect(requests.some((requestArg) => requestArg.url === '/auth/token' && requestArg.auth === 'Basic Y2xpZW50SWQ6Y2xpZW50UGFzcw==')).toBeTrue(); + expect(requests.some((requestArg) => requestArg.url === '/status' && requestArg.auth === 'Bearer token-1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.state === true)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.state === false && entityArg.attributes?.deviceClass === 'door')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && (entityArg.state as { targetTemperature?: number }).targetTemperature === 21)).toBeTrue(); + expect(command.success).toBeTrue(); + expect(actionPayload?.target).toEqual('/capability/cap-switch'); + expect((actionPayload?.params as { onState?: { value?: boolean } }).onState?.value).toBeFalse(); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes LIVISI Smart Home runtime and unsupported control without executor', async () => { + const integration = new LivisiIntegration(); + expect(integration.domain).toEqual("livisi"); + expect(integration.status).toEqual("control-runtime"); + expect(livisiProfile.metadata.configFlow).toEqual(true); + expect(livisiProfile.metadata.requirements).toEqual([ + "livisi==0.0.25", + ]); + + const runtime = await integration.setup({ name: "LIVISI Smart Home Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "livisi", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "livisi", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILivisiSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LIVISI Smart Home Device"); + + const command = await runtime.callService!({ domain: "livisi", service: livisiProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +const handleLivisiRequest = async ( + requestArg: IncomingMessage, + responseArg: ServerResponse, + requestsArg: Array<{ method?: string; url?: string; auth?: string; body?: unknown }>, + onActionArg: (payloadArg: Record) => void, +): Promise => { + const bodyText = await readRequestBody(requestArg); + const body = bodyText ? JSON.parse(bodyText) as unknown : undefined; + requestsArg.push({ method: requestArg.method, url: requestArg.url, auth: requestArg.headers.authorization, body }); + + if (requestArg.url === '/auth/token' && requestArg.method === 'POST') { + json(responseArg, { access_token: 'token-1' }); + return; + } + + if (requestArg.headers.authorization !== 'Bearer token-1') { + json(responseArg, { errorcode: 2009 }, 401); + return; + } + + if (requestArg.url === '/status') { + json(responseArg, { controllerType: 'Avatar', serialNumber: 'SHC-123' }); + return; + } + + if (requestArg.url === '/device') { + json(responseArg, [ + { id: 'switch-1', type: 'PSS', manufacturer: 'Livisi', config: { name: 'Coffee Plug' }, location: '/location/room-1' }, + { id: 'door-1', type: 'WDS', manufacturer: 'Livisi', config: { name: 'Front Door' }, tags: { typeCategory: 'TCDoorId' }, location: '/location/room-1' }, + { id: 'climate-1', type: 'VRCC', manufacturer: 'Livisi', config: { name: 'Climate' }, location: '/location/room-1' }, + ]); + return; + } + + if (requestArg.url === '/capability') { + json(responseArg, [ + { id: 'cap-switch', type: 'SwitchActuator', device: '/device/switch-1' }, + { id: 'cap-door', type: 'WindowDoorSensor', device: '/device/door-1' }, + { id: 'cap-setpoint', type: 'RoomSetpoint', device: '/device/climate-1', config: { minTemperature: 6, maxTemperature: 30 } }, + { id: 'cap-temperature', type: 'RoomTemperature', device: '/device/climate-1' }, + { id: 'cap-humidity', type: 'RoomHumidity', device: '/device/climate-1' }, + ]); + return; + } + + if (requestArg.url === '/location') { + json(responseArg, [{ id: 'room-1', config: { name: 'Hallway' } }]); + return; + } + + if (requestArg.url === '/capability/cap-switch/state') { + json(responseArg, { onState: { value: true } }); + return; + } + + if (requestArg.url === '/capability/cap-door/state') { + json(responseArg, { isOpen: { value: false } }); + return; + } + + if (requestArg.url === '/capability/cap-setpoint/state') { + json(responseArg, { setpointTemperature: { value: 21 } }); + return; + } + + if (requestArg.url === '/capability/cap-temperature/state') { + json(responseArg, { temperature: { value: 20.5 } }); + return; + } + + if (requestArg.url === '/capability/cap-humidity/state') { + json(responseArg, { humidity: { value: 45 } }); + return; + } + + if (requestArg.url === '/action' && requestArg.method === 'POST') { + onActionArg(body as Record); + json(responseArg, { resultCode: 'Success' }); + return; + } + + json(responseArg, { error: 'not found' }, 404); +}; + +const readRequestBody = async (requestArg: IncomingMessage): Promise => new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + requestArg.on('data', (chunkArg) => chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg))); + requestArg.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + requestArg.on('error', reject); +}); + +const json = (responseArg: ServerResponse, dataArg: unknown, statusArg = 200): void => { + responseArg.statusCode = statusArg; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(dataArg)); +}; + +export default tap.start(); diff --git a/test/local_calendar/sample.ics b/test/local_calendar/sample.ics new file mode 100644 index 0000000..d254b67 --- /dev/null +++ b/test/local_calendar/sample.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//homeassistant.io//local_calendar 1.0//EN +BEGIN:VEVENT +UID:maintenance-2030@example.test +SUMMARY:Future Maintenance +DTSTART:20300102T030405Z +DTEND:20300102T040506Z +DESCRIPTION:Replace filters +LOCATION:Utility Room +END:VEVENT +BEGIN:VEVENT +UID:holiday-2030@example.test +SUMMARY:All Day Holiday +DTSTART;VALUE=DATE:20300105 +DTEND;VALUE=DATE:20300106 +RRULE:FREQ=YEARLY;COUNT=2 +END:VEVENT +END:VCALENDAR diff --git a/test/local_calendar/test.local_calendar.node.ts b/test/local_calendar/test.local_calendar.node.ts new file mode 100644 index 0000000..f62689c --- /dev/null +++ b/test/local_calendar/test.local_calendar.node.ts @@ -0,0 +1,96 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LocalCalendarClient, LocalCalendarConfigFlow, LocalCalendarIntegration, LocalCalendarMapper, createLocalCalendarDiscoveryDescriptor, localCalendarProfile, type ILocalCalendarSnapshot, type TLocalCalendarRawData } from '../../ts/integrations/local_calendar/index.js'; + +const rawData: TLocalCalendarRawData = { + device: { + id: 'local_calendar-device-1', + name: "Local Calendar Device", + manufacturer: "Local Calendar", + model: "Local Calendar local integration", + serialNumber: 'local_calendar-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "local_calendar" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Local Calendar candidates and creates config flow output', async () => { + const descriptor = createLocalCalendarDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'local_calendar-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'local_calendar-device-1', name: "Local Calendar Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("local_calendar"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LocalCalendarConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('local_calendar-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Local Calendar raw snapshots to runtime devices and entities', async () => { + const client = new LocalCalendarClient({ name: "Local Calendar Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LocalCalendarMapper.toSnapshotFromRaw({ name: "Local Calendar Runtime" }, rawData); + const devices = LocalCalendarMapper.toDevices(mappedSnapshot); + const entities = LocalCalendarMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("local_calendar"); + expect(devices[0].manufacturer).toEqual("Local Calendar"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "local_calendar" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads Local Calendar events from a local ICS file', async () => { + const client = new LocalCalendarClient({ name: 'Maintenance Calendar', filePath: 'test/local_calendar/sample.ics' }); + const snapshot = await client.getSnapshot(true); + const entities = LocalCalendarMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.model).toEqual('Local iCalendar file'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'event_count')?.state).toEqual(2); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'next_event')?.state).toEqual('Future Maintenance'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'next_event')?.attributes?.location).toEqual('Utility Room'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.maintenance_calendar_next_event')?.state).toEqual('Future Maintenance'); + + const runtime = await new LocalCalendarIntegration().setup({ name: 'Maintenance Calendar', icsFile: 'test/local_calendar/sample.ics' }, {}); + const status = await runtime.callService!({ domain: 'local_calendar', service: 'status', target: {} }); + const runtimeSnapshot = status.data as ILocalCalendarSnapshot; + + expect(status.success).toBeTrue(); + expect(runtimeSnapshot.entities.find((entityArg) => entityArg.id === 'event_count')?.state).toEqual(2); + await runtime.destroy(); +}); + +tap.test('exposes Local Calendar runtime and unsupported control without executor', async () => { + const integration = new LocalCalendarIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(localCalendarProfile.metadata.configFlow).toEqual(true); + expect(localCalendarProfile.metadata.requirements).toEqual([ + "ical==13.2.2", + ]); + + const runtime = await integration.setup({ name: "Local Calendar Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "local_calendar", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "local_calendar", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILocalCalendarSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Local Calendar Device"); + + const command = await runtime.callService!({ domain: "local_calendar", service: localCalendarProfile.controlServices?.[0] || '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/local_file/sample-one.png b/test/local_file/sample-one.png new file mode 100644 index 0000000..b046568 --- /dev/null +++ b/test/local_file/sample-one.png @@ -0,0 +1 @@ +sample-one-image diff --git a/test/local_file/sample-two.jpg b/test/local_file/sample-two.jpg new file mode 100644 index 0000000..5e3eebc --- /dev/null +++ b/test/local_file/sample-two.jpg @@ -0,0 +1 @@ +sample-two-image diff --git a/test/local_file/test.local_file.node.ts b/test/local_file/test.local_file.node.ts new file mode 100644 index 0000000..4a02f24 --- /dev/null +++ b/test/local_file/test.local_file.node.ts @@ -0,0 +1,95 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LocalFileClient, LocalFileConfigFlow, LocalFileIntegration, LocalFileMapper, createLocalFileDiscoveryDescriptor, localFileProfile, type ILocalFileSnapshot, type TLocalFileRawData } from '../../ts/integrations/local_file/index.js'; + +const rawData: TLocalFileRawData = { + device: { + id: 'local_file-device-1', + name: "Local File Device", + manufacturer: "Local File", + model: "Local File local integration", + serialNumber: 'local_file-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "local_file" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Local File candidates and creates config flow output', async () => { + const descriptor = createLocalFileDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'local_file-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'local_file-device-1', name: "Local File Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("local_file"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LocalFileConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('local_file-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Local File raw snapshots to runtime devices and entities', async () => { + const client = new LocalFileClient({ name: "Local File Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LocalFileMapper.toSnapshotFromRaw({ name: "Local File Runtime" }, rawData); + const devices = LocalFileMapper.toDevices(mappedSnapshot); + const entities = LocalFileMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("local_file"); + expect(devices[0].manufacturer).toEqual("Local File"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "local_file" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads Local File image bytes from a local path and updates the path natively', async () => { + const client = new LocalFileClient({ name: 'Door Camera', filePath: 'test/local_file/sample-one.png' }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.model).toEqual('Local File Camera'); + expect(snapshot.entities[0].state).toEqual(17); + expect(snapshot.entities[0].attributes?.contentType).toEqual('image/png'); + + const update = await client.execute({ domain: 'local_file', service: 'update_file_path', target: {}, data: { file_path: 'test/local_file/sample-two.jpg' } }); + const updatedSnapshot = update.data as ILocalFileSnapshot; + + expect(update.success).toBeTrue(); + expect(updatedSnapshot.entities[0].state).toEqual(17); + expect(updatedSnapshot.entities[0].attributes?.contentType).toEqual('image/jpeg'); + expect(updatedSnapshot.entities[0].attributes?.file_path).toEqual('test/local_file/sample-two.jpg'); +}); + +tap.test('exposes Local File runtime, native path update, and unsupported non-native control without executor', async () => { + const integration = new LocalFileIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(localFileProfile.metadata.configFlow).toEqual(true); + + const runtime = await integration.setup({ name: "Local File Runtime", filePath: 'test/local_file/sample-one.png' }, {}); + const statusResult = await runtime.callService!({ domain: "local_file", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "local_file", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILocalFileSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.entities[0].attributes?.contentType).toEqual('image/png'); + expect((await runtime.devices())[0].name).toEqual("Local File Runtime"); + + const update = await runtime.callService!({ domain: "local_file", service: 'update_file_path', target: {}, data: { file_path: 'test/local_file/sample-two.jpg' } }); + expect(update.success).toBeTrue(); + expect(((update.data as ILocalFileSnapshot).entities[0].attributes?.contentType)).toEqual('image/jpeg'); + + const command = await runtime.callService!({ domain: "local_file", 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/local_ip/test.local_ip.node.ts b/test/local_ip/test.local_ip.node.ts new file mode 100644 index 0000000..58ed0f3 --- /dev/null +++ b/test/local_ip/test.local_ip.node.ts @@ -0,0 +1,100 @@ +import { createServer } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LocalIpClient, LocalIpConfigFlow, LocalIpIntegration, LocalIpMapper, createLocalIpDiscoveryDescriptor, localIpProfile, type ILocalIpSnapshot, type TLocalIpRawData } from '../../ts/integrations/local_ip/index.js'; + +const rawData: TLocalIpRawData = { + device: { + id: 'local_ip-device-1', + name: "Local IP Address Device", + manufacturer: "Local IP Address", + model: "Local IP Address local integration", + serialNumber: 'local_ip-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "local_ip" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Local IP Address candidates and creates config flow output', async () => { + const descriptor = createLocalIpDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'local_ip-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'local_ip-device-1', name: "Local IP Address Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("local_ip"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LocalIpConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('local_ip-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Local IP Address raw snapshots to runtime devices and entities', async () => { + const client = new LocalIpClient({ name: "Local IP Address Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LocalIpMapper.toSnapshotFromRaw({ name: "Local IP Address Runtime" }, rawData); + const devices = LocalIpMapper.toDevices(mappedSnapshot); + const entities = LocalIpMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("local_ip"); + expect(devices[0].manufacturer).toEqual("Local IP Address"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "local_ip" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('resolves Local IP Address with a local TCP source-address probe', async () => { + const server = createServer((socketArg) => socketArg.end()); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const client = new LocalIpClient({ name: 'Route Probe', targetHost: '127.0.0.1', targetPort: port, timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('tcp'); + expect(snapshot.entities[0].state).toEqual('127.0.0.1'); + expect(snapshot.entities[0].attributes?.targetPort).toEqual(port); + + const runtime = await new LocalIpIntegration().setup({ name: 'Route Probe', targetHost: '127.0.0.1', targetPort: port, timeoutMs: 1000 }, {}); + const status = await runtime.callService!({ domain: 'local_ip', service: 'status', target: {} }); + const runtimeSnapshot = status.data as ILocalIpSnapshot; + + expect(status.success).toBeTrue(); + expect(runtimeSnapshot.entities[0].state).toEqual('127.0.0.1'); + await runtime.destroy(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Local IP Address runtime and unsupported control without executor', async () => { + const integration = new LocalIpIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(localIpProfile.metadata.configFlow).toEqual(true); + + const runtime = await integration.setup({ name: "Local IP Address Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "local_ip", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "local_ip", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILocalIpSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Local IP Address Device"); + + const command = await runtime.callService!({ domain: "local_ip", service: localIpProfile.controlServices?.[0] || '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/local_todo/sample.ics b/test/local_todo/sample.ics new file mode 100644 index 0000000..0abe44f --- /dev/null +++ b/test/local_todo/sample.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +PRODID:-//homeassistant.io//local_todo 2.0//EN +VERSION:2.0 +BEGIN:VTODO +UID:todo-1 +SUMMARY:Buy milk +STATUS:NEEDS-ACTION +DUE;VALUE=DATE:20260513 +DESCRIPTION:Organic whole milk +END:VTODO +BEGIN:VTODO +UID:todo-2 +SUMMARY:File receipt +STATUS:COMPLETED +COMPLETED:20260510T120000Z +END:VTODO +END:VCALENDAR diff --git a/test/local_todo/test.local_todo.node.ts b/test/local_todo/test.local_todo.node.ts new file mode 100644 index 0000000..79b3294 --- /dev/null +++ b/test/local_todo/test.local_todo.node.ts @@ -0,0 +1,101 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLocalTodoIntegration, LocalTodoClient, LocalTodoConfigFlow, LocalTodoIntegration, LocalTodoMapper, createLocalTodoDiscoveryDescriptor, localTodoProfile, type ILocalTodoSnapshot, type TLocalTodoRawData } from '../../ts/integrations/local_todo/index.js'; + +const rawData: TLocalTodoRawData = { + device: { + id: 'local_todo-device-1', + name: "Local To-do Device", + manufacturer: "Local To-do", + model: "Local To-do local integration", + serialNumber: 'local_todo-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "local_todo" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Local To-do candidates and creates config flow output', async () => { + const descriptor = createLocalTodoDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'local_todo-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'local_todo-device-1', name: "Local To-do Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("local_todo"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LocalTodoConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('local_todo-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Local To-do raw snapshots to runtime devices and entities', async () => { + const client = new LocalTodoClient({ name: "Local To-do Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LocalTodoMapper.toSnapshotFromRaw({ name: "Local To-do Runtime" }, rawData); + const devices = LocalTodoMapper.toDevices(mappedSnapshot); + const entities = LocalTodoMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("local_todo"); + expect(devices[0].manufacturer).toEqual("Local To-do"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "local_todo" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('reads Home Assistant Local To-do ICS storage files', async () => { + const client = new LocalTodoClient({ name: 'House Tasks', filePath: 'test/local_todo/sample.ics' }); + const snapshot = await client.getSnapshot(true); + const items = snapshot.entities.find((entityArg) => entityArg.id === 'todo_items'); + const needsAction = snapshot.entities.find((entityArg) => entityArg.id === 'needs_action'); + const completed = snapshot.entities.find((entityArg) => entityArg.id === 'completed'); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.name).toEqual('House Tasks'); + expect(items?.state).toEqual(2); + expect(needsAction?.state).toEqual(1); + expect(completed?.state).toEqual(1); + expect(JSON.stringify(items?.attributes?.items)).toContain('"due":"2026-05-12"'); + expect(JSON.stringify(localTodoProfile.metadata.localApi)).toContain('local ICS file'); + + const runtime = await new LocalTodoIntegration().setup({ name: 'House Tasks', filePath: 'test/local_todo/sample.ics' }, {}); + const status = await runtime.callService!({ domain: 'local_todo', service: 'status', target: {} }); + const runtimeSnapshot = status.data as ILocalTodoSnapshot; + expect(status.success).toBeTrue(); + expect(runtimeSnapshot.entities.find((entityArg) => entityArg.id === 'todo_items')?.state).toEqual(2); + await runtime.destroy(); +}); + +tap.test('exposes Local To-do runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LocalTodoIntegration(); + const alias = new HomeAssistantLocalTodoIntegration(); + expect(alias instanceof LocalTodoIntegration).toBeTrue(); + expect(alias.domain).toEqual("local_todo"); + expect(integration.status).toEqual("read-only-runtime"); + expect(localTodoProfile.metadata.configFlow).toEqual(true); + expect(localTodoProfile.metadata.requirements).toEqual([ + "ical==13.2.2", + ]); + + const runtime = await integration.setup({ name: "Local To-do Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "local_todo", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "local_todo", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILocalTodoSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Local To-do Device"); + + const command = await runtime.callService!({ domain: "local_todo", service: localTodoProfile.controlServices?.[0] || '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/locative/test.locative.node.ts b/test/locative/test.locative.node.ts new file mode 100644 index 0000000..de24aff --- /dev/null +++ b/test/locative/test.locative.node.ts @@ -0,0 +1,78 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLocativeIntegration, LocativeClient, LocativeConfigFlow, LocativeIntegration, LocativeMapper, createLocativeDiscoveryDescriptor, locativeProfile, type ILocativeSnapshot, type TLocativeRawData } from '../../ts/integrations/locative/index.js'; + +const rawData: TLocativeRawData = { + device: { + id: 'locative-device-1', + name: "Locative Device", + manufacturer: "Locative", + model: "Locative local integration", + serialNumber: 'locative-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "locative" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Locative candidates and creates config flow output', async () => { + const descriptor = createLocativeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'locative-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'locative-device-1', name: "Locative Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("locative"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LocativeConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('locative-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Locative raw snapshots to runtime devices and entities', async () => { + const client = new LocativeClient({ name: "Locative Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LocativeMapper.toSnapshotFromRaw({ name: "Locative Runtime" }, rawData); + const devices = LocativeMapper.toDevices(mappedSnapshot); + const entities = LocativeMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("locative"); + expect(devices[0].manufacturer).toEqual("Locative"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "locative" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes Locative runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LocativeIntegration(); + const alias = new HomeAssistantLocativeIntegration(); + expect(alias instanceof LocativeIntegration).toBeTrue(); + expect(alias.domain).toEqual("locative"); + expect(integration.status).toEqual("read-only-runtime"); + expect(locativeProfile.metadata.configFlow).toEqual(true); + expect(locativeProfile.metadata.requirements).toEqual([]); + expect(JSON.stringify(locativeProfile.metadata.runtime)).toContain('device_tracker'); + expect(JSON.stringify(locativeProfile.metadata.localApi)).toContain('inbound webhook receiver'); + + const runtime = await integration.setup({ name: "Locative Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "locative", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "locative", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILocativeSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Locative Device"); + + const command = await runtime.callService!({ domain: "locative", service: locativeProfile.controlServices?.[0] || '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/lookin/test.lookin.node.ts b/test/lookin/test.lookin.node.ts new file mode 100644 index 0000000..f63fc61 --- /dev/null +++ b/test/lookin/test.lookin.node.ts @@ -0,0 +1,139 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLookinIntegration, LookinClient, LookinConfigFlow, LookinIntegration, LookinMapper, createLookinDiscoveryDescriptor, lookinProfile, type ILookinSnapshot, type TLookinRawData } from '../../ts/integrations/lookin/index.js'; + +const rawData: TLookinRawData = { + device: { + id: 'lookin-device-1', + name: "LOOKin Device", + manufacturer: "LOOKin", + model: "LOOKin local integration", + serialNumber: 'lookin-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "climate", state: true, attributes: { domain: "lookin" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LOOKin candidates and creates config flow output', async () => { + const descriptor = createLookinDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lookin-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lookin-device-1', name: "LOOKin Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lookin"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LookinConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lookin-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LOOKin raw snapshots to runtime devices and entities', async () => { + const client = new LookinClient({ name: "LOOKin Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LookinMapper.toSnapshotFromRaw({ name: "LOOKin Runtime" }, rawData); + const devices = LookinMapper.toDevices(mappedSnapshot); + const entities = LookinMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lookin"); + expect(devices[0].manufacturer).toEqual("LOOKin"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lookin" && entityArg.platform === "climate")).toBeTrue(); +}); + +tap.test('polls LOOKin local HTTP endpoints and sends native IR commands', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL) => { + const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url; + requests.push(url); + const parsed = new URL(url); + + if (parsed.pathname === '/device') { + return jsonResponse({ Type: 'Remote', MRDC: '02FF', Status: 'ok', ID: '98f33093', Name: 'Living LOOKin', Firmware: '1.2.3' }); + } + if (parsed.pathname === '/data') { + return jsonResponse([ + { Type: '01', UUID: 'TV01' }, + { Type: '03', UUID: 'LT01' }, + { Type: 'EF', UUID: 'AC01' }, + ]); + } + if (parsed.pathname === '/data/TV01') { + return jsonResponse({ Type: '01', UUID: 'TV01', Name: 'TV', Updated: '1', Status: '10F0', Functions: [{ Name: 'poweron', Type: 'button' }, { Name: 'poweroff', Type: 'button' }, { Name: 'volup', Type: 'button' }] }); + } + if (parsed.pathname === '/data/LT01') { + return jsonResponse({ Type: '03', UUID: 'LT01', Name: 'Lamp', Updated: '1', Status: '1000', Functions: [{ Name: 'power', Type: 'button' }] }); + } + if (parsed.pathname === '/data/AC01') { + return jsonResponse({ Type: 'EF', UUID: 'AC01', Name: 'AC', Updated: '1', Status: '1821', Extra: '', Functions: [{ Name: 'power', Type: 'button' }] }); + } + if (parsed.pathname === '/sensors/meteo') { + return jsonResponse({ Humidity: '41.5', Pressure: '1000', Temperature: '22.3', Updated: '1' }); + } + if (parsed.pathname === '/commands/ir/localremote/TV0106FF') { + return new Response(null, { status: 204 }); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + try { + const client = new LookinClient({ host: 'lookin.local', timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + expect(snapshot.online).toBeTrue(); + expect(snapshot.device.id).toEqual('98F33093'); + expect(snapshot.entities.some((entityArg) => entityArg.platform === 'media_player' && entityArg.id === 'TV01')).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.platform === 'light' && entityArg.id === 'LT01')).toBeTrue(); + expect(snapshot.entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.id === 'AC01')).toBeTrue(); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'temperature')?.state).toEqual(22.3); + + const command = await client.execute({ domain: 'media_player', service: 'volume_up', target: {}, data: { uuid: 'TV01' } }); + expect(command.success).toBeTrue(); + expect(requests).toContain('http://lookin.local/device'); + expect(requests).toContain('http://lookin.local/data/TV01'); + expect(requests).toContain('http://lookin.local/commands/ir/localremote/TV0106FF'); + expect(JSON.stringify(lookinProfile.metadata.localApi)).toContain('/device'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('exposes LOOKin runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LookinIntegration(); + const alias = new HomeAssistantLookinIntegration(); + expect(alias instanceof LookinIntegration).toBeTrue(); + expect(alias.domain).toEqual("lookin"); + expect(integration.status).toEqual("control-runtime"); + expect(lookinProfile.metadata.configFlow).toEqual(true); + expect(lookinProfile.metadata.requirements).toEqual([ + "aiolookin==1.0.0", + ]); + + const runtime = await integration.setup({ name: "LOOKin Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lookin", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lookin", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILookinSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LOOKin Device"); + + const command = await runtime.callService!({ domain: "lookin", service: lookinProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('requires an injected client.execute() or commandExecutor'); + await runtime.destroy(); +}); + +const jsonResponse = (valueArg: unknown): Response => { + return new Response(JSON.stringify(valueArg), { headers: { 'content-type': 'application/json' } }); +}; + +export default tap.start(); diff --git a/test/loqed/test.loqed.client_runtime.node.ts b/test/loqed/test.loqed.client_runtime.node.ts new file mode 100644 index 0000000..11c0862 --- /dev/null +++ b/test/loqed/test.loqed.client_runtime.node.ts @@ -0,0 +1,107 @@ +import * as http from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LoqedClient, LoqedIntegration } from '../../ts/integrations/loqed/index.js'; + +interface ILoqedTestServer { + url: string; + commands: string[]; + close(): Promise; +} + +const statusPayload = { + battery_percentage: 87, + battery_type: 'AA', + battery_voltage: 5.8, + bolt_state: 'night_lock', + bridge_mac_wifi: 'AA:BB:CC:DD:EE:FF', + bridge_mac_ble: '11:22:33:44:55:66', + lock_online: 1, + webhooks_number: 1, + ip_address: '192.168.1.40', + up_timestamp: 12345, + wifi_strength: -55, + ble_strength: -60, +}; + +const startLoqedServer = async (): Promise => { + const commands: string[] = []; + const server = http.createServer((requestArg, responseArg) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + if (requestArg.method === 'GET' && url.pathname === '/status') { + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(statusPayload)); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/to_lock') { + const command = url.searchParams.get('command_signed_base64') || ''; + commands.push(command); + responseArg.end('OK'); + return; + } + responseArg.statusCode = 404; + responseArg.end('not found'); + }); + await listen(server); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + commands, + close: async () => close(server), + }; +}; + +tap.test('reads LOQED bridge status and sends signed local commands', async () => { + const server = await startLoqedServer(); + const secret = 'U2VjcmV0U2VjcmV0U2VjcmV0U2VjcmV0U2VjcmV0U2VjcmU='; + try { + const client = new LoqedClient({ url: server.url, timeoutMs: 1000, name: 'Front Door', lockKeySecret: secret, lockKeyLocalId: 1 }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LoqedIntegration().setup({ url: server.url, timeoutMs: 1000, name: 'Front Door', lockKeySecret: secret, lockKeyLocalId: 1 }, {}); + const unlock = await runtime.callService?.({ domain: 'lock', service: 'unlock', target: { entityId: 'switch.front_door_lock' } }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'lock')?.state).toBeTrue(); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'battery')?.state).toEqual(87); + expect(unlock?.success).toBeTrue(); + expect(server.commands.length).toEqual(1); + expect(Buffer.from(server.commands[0], 'base64').length).toEqual(53); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +tap.test('does not fake LOQED control without local key material', async () => { + const server = await startLoqedServer(); + try { + const runtime = await new LoqedIntegration().setup({ url: server.url, timeoutMs: 1000, name: 'Front Door' }, {}); + const result = await runtime.callService?.({ domain: 'lock', service: 'lock', target: {} }); + + expect(result?.success).toBeFalse(); + expect(result?.error || '').toContain('lockKeySecret'); + expect(server.commands.length).toEqual(0); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +const listen = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.once('error', reject); + serverArg.listen(0, '127.0.0.1', () => { + serverArg.off('error', reject); + resolve(); + }); + }); +}; + +const close = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.close((errorArg) => errorArg ? reject(errorArg) : resolve()); + }); +}; + +export default tap.start(); diff --git a/test/loqed/test.loqed.node.ts b/test/loqed/test.loqed.node.ts new file mode 100644 index 0000000..bab0421 --- /dev/null +++ b/test/loqed/test.loqed.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LoqedClient, LoqedConfigFlow, LoqedIntegration, LoqedMapper, createLoqedDiscoveryDescriptor, loqedProfile, type ILoqedSnapshot, type TLoqedRawData } from '../../ts/integrations/loqed/index.js'; + +const rawData: TLoqedRawData = { + device: { + id: 'loqed-device-1', + name: "LOQED Touch Smart Lock Device", + manufacturer: "LOQED Touch Smart Lock", + model: "LOQED Touch Smart Lock local integration", + serialNumber: 'loqed-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "loqed" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual LOQED Touch Smart Lock candidates and creates config flow output', async () => { + const descriptor = createLoqedDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'loqed-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'loqed-device-1', name: "LOQED Touch Smart Lock Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("loqed"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LoqedConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('loqed-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps LOQED Touch Smart Lock raw snapshots to runtime devices and entities', async () => { + const client = new LoqedClient({ name: "LOQED Touch Smart Lock Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LoqedMapper.toSnapshotFromRaw({ name: "LOQED Touch Smart Lock Runtime" }, rawData); + const devices = LoqedMapper.toDevices(mappedSnapshot); + const entities = LoqedMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("loqed"); + expect(devices[0].manufacturer).toEqual("LOQED Touch Smart Lock"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "loqed" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes LOQED Touch Smart Lock runtime and unsupported control without executor', async () => { + const integration = new LoqedIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(loqedProfile.metadata.configFlow).toEqual(true); + expect(loqedProfile.metadata.requirements).toEqual([ + "loqedAPI==2.1.11", + ]); + + const runtime = await integration.setup({ name: "LOQED Touch Smart Lock Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "loqed", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "loqed", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILoqedSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("LOQED Touch Smart Lock Device"); + + const command = await runtime.callService!({ domain: "loqed", service: loqedProfile.controlServices?.[0] || '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/luci/test.luci.client_runtime.node.ts b/test/luci/test.luci.client_runtime.node.ts new file mode 100644 index 0000000..d8a277b --- /dev/null +++ b/test/luci/test.luci.client_runtime.node.ts @@ -0,0 +1,109 @@ +import * as http from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LuciClient, LuciIntegration } from '../../ts/integrations/luci/index.js'; + +interface ILuciTestServer { + url: string; + calls: Array<{ path?: string; method?: string }>; + close(): Promise; +} + +const readJson = async (requestArg: http.IncomingMessage): Promise> => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as Record; +}; + +const sendRpc = (responseArg: http.ServerResponse, resultArg: unknown, errorArg: unknown = null): void => { + responseArg.statusCode = 200; + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify({ result: resultArg, error: errorArg })); +}; + +const startLuciServer = async (): Promise => { + const calls: Array<{ path?: string; method?: string }> = []; + const server = http.createServer(async (requestArg, responseArg) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + const body = await readJson(requestArg); + const method = String(body.method || ''); + calls.push({ path: url.pathname, method }); + + if (url.pathname === '/cgi-bin/luci/rpc/auth' && method === 'login') { + sendRpc(responseArg, 'test-token'); + return; + } + if (url.searchParams.get('auth') !== 'test-token') { + responseArg.statusCode = 403; + responseArg.end('invalid token'); + return; + } + if (url.pathname === '/cgi-bin/luci/rpc/sys' && method === 'exec') { + sendRpc(responseArg, '19.07.0\n'); + return; + } + if (url.pathname === '/cgi-bin/luci/rpc/ip' && method === 'neighbors') { + sendRpc(responseArg, [ + { mac: 'AA:BB:CC:DD:EE:FF', dest: '192.168.1.20', reachable: true, dev: 'br-lan' }, + { mac: '11:22:33:44:55:66', dest: '192.168.1.30', reachable: false, dev: 'br-lan' }, + ]); + return; + } + if (url.pathname === '/cgi-bin/luci/rpc/uci' && method === 'get_all') { + sendRpc(responseArg, { + cfg1: { '.type': 'host', mac: 'AA:BB:CC:DD:EE:FF', name: 'phone' }, + }); + return; + } + responseArg.statusCode = 404; + responseArg.end('not found'); + }); + await listen(server); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + calls, + close: async () => close(server), + }; +}; + +tap.test('reads OpenWrt LuCI JSON-RPC devices over native HTTP', async () => { + const server = await startLuciServer(); + try { + const client = new LuciClient({ url: server.url, username: 'root', password: 'secret', timeoutMs: 1000, name: 'OpenWrt Router' }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LuciIntegration().setup({ url: server.url, username: 'root', password: 'secret', timeoutMs: 1000, name: 'OpenWrt Router' }, {}); + const entities = await runtime.entities(); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'connected_devices')?.state).toEqual(1); + expect(snapshot.entities.some((entityArg) => entityArg.name === 'phone Connected')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.name === 'phone Connected' && entityArg.available)).toBeTrue(); + expect(server.calls.some((callArg) => callArg.path === '/cgi-bin/luci/rpc/auth' && callArg.method === 'login')).toBeTrue(); + expect(server.calls.some((callArg) => callArg.path === '/cgi-bin/luci/rpc/ip' && callArg.method === 'neighbors')).toBeTrue(); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +const listen = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.once('error', reject); + serverArg.listen(0, '127.0.0.1', () => { + serverArg.off('error', reject); + resolve(); + }); + }); +}; + +const close = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.close((errorArg) => errorArg ? reject(errorArg) : resolve()); + }); +}; + +export default tap.start(); diff --git a/test/luci/test.luci.node.ts b/test/luci/test.luci.node.ts new file mode 100644 index 0000000..881763a --- /dev/null +++ b/test/luci/test.luci.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LuciClient, LuciConfigFlow, LuciIntegration, LuciMapper, createLuciDiscoveryDescriptor, luciProfile, type ILuciSnapshot, type TLuciRawData } from '../../ts/integrations/luci/index.js'; + +const rawData: TLuciRawData = { + device: { + id: 'luci-device-1', + name: "OpenWrt (luci) Device", + manufacturer: "OpenWrt (luci)", + model: "OpenWrt (luci) local integration", + serialNumber: 'luci-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "luci" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual OpenWrt (luci) candidates and creates config flow output', async () => { + const descriptor = createLuciDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'luci-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'luci-device-1', name: "OpenWrt (luci) Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("luci"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LuciConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('luci-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps OpenWrt (luci) raw snapshots to runtime devices and entities', async () => { + const client = new LuciClient({ name: "OpenWrt (luci) Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LuciMapper.toSnapshotFromRaw({ name: "OpenWrt (luci) Runtime" }, rawData); + const devices = LuciMapper.toDevices(mappedSnapshot); + const entities = LuciMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("luci"); + expect(devices[0].manufacturer).toEqual("OpenWrt (luci)"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "luci" && entityArg.platform === "sensor")).toBeTrue(); +}); + +tap.test('exposes OpenWrt (luci) runtime and unsupported control without executor', async () => { + const integration = new LuciIntegration(); + expect(integration.status).toEqual("read-only-runtime"); + expect(luciProfile.metadata.configFlow).toEqual(false); + expect(luciProfile.metadata.requirements).toEqual([ + "openwrt-luci-rpc==1.1.17", + ]); + + const runtime = await integration.setup({ name: "OpenWrt (luci) Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "luci", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "luci", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILuciSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("OpenWrt (luci) Device"); + + const command = await runtime.callService!({ domain: "luci", service: luciProfile.controlServices?.[0] || '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/lunatone/test.lunatone.client_runtime.node.ts b/test/lunatone/test.lunatone.client_runtime.node.ts new file mode 100644 index 0000000..1b37c8d --- /dev/null +++ b/test/lunatone/test.lunatone.client_runtime.node.ts @@ -0,0 +1,140 @@ +import * as http from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LunatoneClient, LunatoneIntegration } from '../../ts/integrations/lunatone/index.js'; + +interface ILunatoneTestServer { + url: string; + controls: Array<{ path?: string; body: Record }>; + close(): Promise; +} + +const infoPayload = { + name: 'DALI-2 Gateway', + version: 'v1.17.0/1.4.3', + tier: 'plus', + uid: 'lunatone-uid-1', + descriptor: { lines: 1 }, + device: { + serial: 1234, + gtin: 9010342013577, + pcb: '9a', + articleNumber: 89453886, + articleInfo: '', + productionYear: 2024, + productionWeek: 1, + }, + lines: { + '0': { + lineStatus: 'ok', + device: { + serial: 1234, + gtin: 9010342013577, + pcb: '9a', + articleNumber: 89453886, + articleInfo: '', + productionYear: 2024, + productionWeek: 1, + }, + }, + }, +}; + +const devicesPayload = { + devices: [ + { + id: 1, + name: 'Office Light', + type: 'default', + available: true, + address: 0, + line: 0, + features: { + switchable: { status: true }, + dimmable: { status: 75 }, + colorRGB: { status: { r: 1, g: 0.5, b: 0 } }, + }, + groups: [], + daliTypes: [], + }, + ], +}; + +const readJson = async (requestArg: http.IncomingMessage): Promise> => { + const chunks: Buffer[] = []; + for await (const chunk of requestArg) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = Buffer.concat(chunks).toString('utf8'); + return body ? JSON.parse(body) as Record : {}; +}; + +const startLunatoneServer = async (): Promise => { + const controls: Array<{ path?: string; body: Record }> = []; + const server = http.createServer(async (requestArg, responseArg) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + responseArg.setHeader('content-type', 'application/json'); + if (requestArg.method === 'GET' && url.pathname === '/info') { + responseArg.end(JSON.stringify(infoPayload)); + return; + } + if (requestArg.method === 'GET' && url.pathname === '/devices') { + responseArg.end(JSON.stringify(devicesPayload)); + return; + } + if (requestArg.method === 'POST' && url.pathname === '/device/1/control') { + controls.push({ path: requestArg.url, body: await readJson(requestArg) }); + responseArg.end(JSON.stringify({ ok: true })); + return; + } + responseArg.statusCode = 404; + responseArg.end(JSON.stringify({ error: 'not found' })); + }); + await listen(server); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + controls, + close: async () => close(server), + }; +}; + +tap.test('reads and controls Lunatone REST lights over native HTTP', async () => { + const server = await startLunatoneServer(); + try { + const client = new LunatoneClient({ url: server.url, timeoutMs: 1000, name: 'Gateway' }); + const snapshot = await client.getSnapshot(true); + const runtime = await new LunatoneIntegration().setup({ url: server.url, timeoutMs: 1000, name: 'Gateway' }, {}); + const entities = await runtime.entities(); + const turnOff = await runtime.callService?.({ domain: 'light', service: 'turn_off', target: { entityId: 'light.gateway_device_1' } }); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.entities.find((entityArg) => entityArg.id === 'device_1')?.state).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.gateway_device_1' && entityArg.state === true)).toBeTrue(); + expect(turnOff?.success).toBeTrue(); + expect(server.controls[0]?.path).toEqual('/device/1/control'); + expect(server.controls[0]?.body).toEqual({ dimmable: 0 }); + await runtime.destroy(); + } finally { + await server.close(); + } +}); + +const listen = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.once('error', reject); + serverArg.listen(0, '127.0.0.1', () => { + serverArg.off('error', reject); + resolve(); + }); + }); +}; + +const close = async (serverArg: http.Server): Promise => { + await new Promise((resolve, reject) => { + serverArg.close((errorArg) => errorArg ? reject(errorArg) : resolve()); + }); +}; + +export default tap.start(); diff --git a/test/lunatone/test.lunatone.node.ts b/test/lunatone/test.lunatone.node.ts new file mode 100644 index 0000000..3441224 --- /dev/null +++ b/test/lunatone/test.lunatone.node.ts @@ -0,0 +1,75 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { LunatoneClient, LunatoneConfigFlow, LunatoneIntegration, LunatoneMapper, createLunatoneDiscoveryDescriptor, lunatoneProfile, type ILunatoneSnapshot, type TLunatoneRawData } from '../../ts/integrations/lunatone/index.js'; + +const rawData: TLunatoneRawData = { + device: { + id: 'lunatone-device-1', + name: "Lunatone Device", + manufacturer: "Lunatone", + model: "Lunatone local integration", + serialNumber: 'lunatone-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "light", state: true, attributes: { domain: "lunatone" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Lunatone candidates and creates config flow output', async () => { + const descriptor = createLunatoneDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lunatone-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lunatone-device-1', name: "Lunatone Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lunatone"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LunatoneConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lunatone-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Lunatone raw snapshots to runtime devices and entities', async () => { + const client = new LunatoneClient({ name: "Lunatone Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LunatoneMapper.toSnapshotFromRaw({ name: "Lunatone Runtime" }, rawData); + const devices = LunatoneMapper.toDevices(mappedSnapshot); + const entities = LunatoneMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lunatone"); + expect(devices[0].manufacturer).toEqual("Lunatone"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lunatone" && entityArg.platform === "light")).toBeTrue(); +}); + +tap.test('exposes Lunatone runtime and unsupported control without executor', async () => { + const integration = new LunatoneIntegration(); + expect(integration.status).toEqual("control-runtime"); + expect(lunatoneProfile.metadata.configFlow).toEqual(true); + expect(lunatoneProfile.metadata.requirements).toEqual([ + "lunatone-rest-api-client==0.9.1", + ]); + + const runtime = await integration.setup({ name: "Lunatone Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lunatone", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lunatone", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILunatoneSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Lunatone Device"); + + const command = await runtime.callService!({ domain: "lunatone", service: lunatoneProfile.controlServices?.[0] || '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/lupusec/test.lupusec.node.ts b/test/lupusec/test.lupusec.node.ts new file mode 100644 index 0000000..8e4e3df --- /dev/null +++ b/test/lupusec/test.lupusec.node.ts @@ -0,0 +1,136 @@ +import { createServer } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLupusecIntegration, LupusecClient, LupusecConfigFlow, LupusecIntegration, LupusecMapper, createLupusecDiscoveryDescriptor, lupusecProfile, type ILupusecSnapshot, type TLupusecRawData } from '../../ts/integrations/lupusec/index.js'; + +const rawData: TLupusecRawData = { + device: { + id: 'lupusec-device-1', + name: "Lupus Electronics LUPUSEC Device", + manufacturer: "Lupus Electronics LUPUSEC", + model: "Lupus Electronics LUPUSEC local integration", + serialNumber: 'lupusec-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lupusec" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Lupus Electronics LUPUSEC candidates and creates config flow output', async () => { + const descriptor = createLupusecDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lupusec-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lupusec-device-1', name: "Lupus Electronics LUPUSEC Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lupusec"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LupusecConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lupusec-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Lupus Electronics LUPUSEC raw snapshots to runtime devices and entities', async () => { + const client = new LupusecClient({ name: "Lupus Electronics LUPUSEC Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LupusecMapper.toSnapshotFromRaw({ name: "Lupus Electronics LUPUSEC Runtime" }, rawData); + const devices = LupusecMapper.toDevices(mappedSnapshot); + const entities = LupusecMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lupusec"); + expect(devices[0].manufacturer).toEqual("Lupus Electronics LUPUSEC"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lupusec" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads LUPUSEC XT2 snapshots through the native local HTTP API', async () => { + const calls: string[] = []; + const server = createServer((requestArg, responseArg) => { + const url = new URL(requestArg.url || '/', 'http://127.0.0.1'); + calls.push(`${requestArg.method} ${url.pathname}`); + + if (url.pathname === '/images/model.gif') { + responseArg.statusCode = 404; + responseArg.end('not xt1'); + return; + } + + expect(requestArg.headers.authorization).toEqual(`Basic ${Buffer.from('user:pass').toString('base64')}`); + responseArg.setHeader('content-type', 'application/json'); + + if (url.pathname === '/action/tokenGet') { + responseArg.end(JSON.stringify({ message: 'token-1' })); + return; + } + expect(requestArg.headers['x-token']).toEqual('token-1'); + + if (url.pathname === '/action/panelCondGet') { + responseArg.end(JSON.stringify({ updates: { mode_a1: '{AREA_MODE_1}', battery: '0' } })); + return; + } + if (url.pathname === '/action/deviceListGet') { + responseArg.end(JSON.stringify({ senrows: [ + { sid: '5', name: 'Front Door', type: 4, openClose: '1', area: 1 }, + { sid: '9', name: 'Water Sensor', type: 5, cond: '0', area: 1 }, + ] })); + return; + } + + responseArg.statusCode = 404; + responseArg.end(JSON.stringify({ error: 'not found' })); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const client = new LupusecClient({ host: '127.0.0.1', port, username: 'user', password: 'pass', timeoutMs: 1000 }); + const snapshot = await client.getSnapshot(true); + const entities = LupusecMapper.toEntities(snapshot); + + expect(snapshot.online).toBeTrue(); + expect(snapshot.source).toEqual('http'); + expect(snapshot.device.model).toEqual('LUPUSEC XT2'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.lupusec_xt2_alarm_mode')?.state).toEqual('Arm'); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.lupusec_xt2_5')?.state).toBeTrue(); + expect(calls.includes('GET /action/tokenGet')).toBeTrue(); + expect(calls.includes('GET /action/deviceListGet')).toBeTrue(); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('exposes Lupus Electronics LUPUSEC runtime, HA alias, and unsupported control without executor', async () => { + const integration = new LupusecIntegration(); + const alias = new HomeAssistantLupusecIntegration(); + expect(alias instanceof LupusecIntegration).toBeTrue(); + expect(alias.domain).toEqual("lupusec"); + expect(integration.status).toEqual("control-runtime"); + expect(lupusecProfile.metadata.configFlow).toEqual(true); + expect(lupusecProfile.metadata.requirements).toEqual([ + "lupupy==0.3.2", + ]); + + const runtime = await integration.setup({ name: "Lupus Electronics LUPUSEC Runtime", rawData }, {}); + const statusResult = await runtime.callService!({ domain: "lupusec", service: 'status', target: {} }); + const refresh = await runtime.callService!({ domain: "lupusec", service: 'refresh', target: {} }); + const snapshot = statusResult.data as ILupusecSnapshot; + + expect(statusResult.success).toBeTrue(); + expect(refresh.success).toBeTrue(); + expect(snapshot.online).toBeTrue(); + expect((await runtime.devices())[0].name).toEqual("Lupus Electronics LUPUSEC Device"); + + const command = await runtime.callService!({ domain: "lupusec", service: lupusecProfile.controlServices?.[0] || 'turn_on', target: {} }); + expect(command.success).toBeFalse(); + expect(command.error!).toContain('native HTTP support is read-only'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/lutron/test.lutron.node.ts b/test/lutron/test.lutron.node.ts new file mode 100644 index 0000000..d6543a9 --- /dev/null +++ b/test/lutron/test.lutron.node.ts @@ -0,0 +1,185 @@ +import { createServer as createHttpServer } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantLutronIntegration, LutronClient, LutronConfigFlow, LutronIntegration, LutronMapper, createLutronDiscoveryDescriptor, lutronProfile, type ILutronSnapshot, type TLutronRawData } from '../../ts/integrations/lutron/index.js'; + +const rawData: TLutronRawData = { + device: { + id: 'lutron-device-1', + name: "Lutron Device", + manufacturer: "Lutron", + model: "Lutron local integration", + serialNumber: 'lutron-serial-1', + }, + entities: [ + { id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lutron" } }, + ], + online: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('matches manual Lutron candidates and creates config flow output', async () => { + const descriptor = createLutronDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lutron-manual-match'); + const result = await matcher!.matches({ source: 'manual', id: 'lutron-device-1', name: "Lutron Device", metadata: { rawData } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual("lutron"); + + const validation = await descriptor.getValidators()[0].validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const done = await (await new LutronConfigFlow().start(result.candidate!, {})).submit!({}); + expect(done.kind).toEqual('done'); + expect(done.config?.uniqueId).toEqual('lutron-device-1'); + expect(done.config?.rawData).toEqual(rawData); +}); + +tap.test('maps Lutron raw snapshots to runtime devices and entities', async () => { + const client = new LutronClient({ name: "Lutron Runtime", rawData }); + const snapshot = await client.getSnapshot(); + const mappedSnapshot = LutronMapper.toSnapshotFromRaw({ name: "Lutron Runtime" }, rawData); + const devices = LutronMapper.toDevices(mappedSnapshot); + const entities = LutronMapper.toEntities(mappedSnapshot); + + expect(snapshot.online).toBeTrue(); + expect(mappedSnapshot.source).toEqual('manual'); + expect(devices[0].integrationDomain).toEqual("lutron"); + expect(devices[0].manufacturer).toEqual("Lutron"); + expect(entities.some((entityArg) => entityArg.integrationDomain === "lutron" && entityArg.platform === "binary_sensor")).toBeTrue(); +}); + +tap.test('reads RadioRA 2 XML over HTTP, queries Telnet levels, and writes Telnet commands', async () => { + const httpCalls: string[] = []; + const telnetCommands: string[] = []; + const xml = ` + + 0123456789abcdef + + + + + + + + + + + + + + +