From 1eebd71e7d95f490ad5dc3e40e3fa9695656815b Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 14:57:06 +0000 Subject: [PATCH] Add native hub protocol integrations --- test/deconz/test.deconz.discovery.node.ts | 49 + test/deconz/test.deconz.mapper.node.ts | 136 ++ test/esphome/test.esphome.discovery.node.ts | 47 + test/esphome/test.esphome.mapper.node.ts | 104 ++ .../test.homekit_controller.discovery.node.ts | 83 ++ .../test.homekit_controller.mapper.node.ts | 176 +++ test/matter/test.matter.discovery.node.ts | 48 + test/matter/test.matter.mapper.node.ts | 90 ++ test/nanoleaf/test.nanoleaf.discovery.node.ts | 54 + test/nanoleaf/test.nanoleaf.mapper.node.ts | 59 + test/tradfri/test.tradfri.discovery.node.ts | 37 + test/tradfri/test.tradfri.mapper.node.ts | 123 ++ test/wiz/test.wiz.discovery.node.ts | 56 + test/wiz/test.wiz.mapper.node.ts | 83 ++ .../test.xiaomi_miio.discovery.node.ts | 51 + .../test.xiaomi_miio.mapper.node.ts | 77 ++ test/yeelight/test.yeelight.discovery.node.ts | 36 + test/yeelight/test.yeelight.mapper.node.ts | 40 + test/zha/test.zha.discovery.node.ts | 19 + test/zha/test.zha.mapper.node.ts | 32 + ts/index.ts | 20 + .../deconz/.generated-by-smarthome-exchange | 1 - .../deconz/deconz.classes.client.ts | 238 ++++ .../deconz/deconz.classes.configflow.ts | 39 + .../deconz/deconz.classes.integration.ts | 295 ++++- ts/integrations/deconz/deconz.discovery.ts | 250 ++++ ts/integrations/deconz/deconz.mapper.ts | 525 ++++++++ ts/integrations/deconz/deconz.types.ts | 271 +++- ts/integrations/deconz/index.ts | 4 + .../esphome/.generated-by-smarthome-exchange | 1 - .../esphome/esphome.classes.client.ts | 116 ++ .../esphome/esphome.classes.configflow.ts | 51 + .../esphome/esphome.classes.integration.ts | 151 ++- ts/integrations/esphome/esphome.discovery.ts | 139 ++ ts/integrations/esphome/esphome.mapper.ts | 529 ++++++++ ts/integrations/esphome/esphome.types.ts | 250 +++- ts/integrations/esphome/index.ts | 4 + ts/integrations/generated/index.ts | 32 +- .../.generated-by-smarthome-exchange | 1 - .../homekit_controller.classes.client.ts | 93 ++ .../homekit_controller.classes.configflow.ts | 143 ++ .../homekit_controller.classes.integration.ts | 108 +- .../homekit_controller.discovery.ts | 286 ++++ .../homekit_controller.mapper.ts | 1169 +++++++++++++++++ .../homekit_controller.types.ts | 181 ++- ts/integrations/homekit_controller/index.ts | 4 + .../matter/.generated-by-smarthome-exchange | 1 - ts/integrations/matter/index.ts | 4 + .../matter/matter.classes.client.ts | 437 ++++++ .../matter/matter.classes.configflow.ts | 40 + .../matter/matter.classes.integration.ts | 225 +++- ts/integrations/matter/matter.discovery.ts | 111 ++ ts/integrations/matter/matter.mapper.ts | 873 ++++++++++++ ts/integrations/matter/matter.types.ts | 181 ++- .../nanoleaf/.generated-by-smarthome-exchange | 1 - ts/integrations/nanoleaf/index.ts | 4 + .../nanoleaf/nanoleaf.classes.client.ts | 280 ++++ .../nanoleaf/nanoleaf.classes.configflow.ts | 29 + .../nanoleaf/nanoleaf.classes.integration.ts | 251 +++- .../nanoleaf/nanoleaf.discovery.ts | 208 +++ ts/integrations/nanoleaf/nanoleaf.mapper.ts | 274 ++++ ts/integrations/nanoleaf/nanoleaf.types.ts | 185 ++- .../tradfri/.generated-by-smarthome-exchange | 1 - ts/integrations/tradfri/index.ts | 6 +- .../tradfri/tradfri.classes.client.ts | 113 ++ .../tradfri/tradfri.classes.configflow.ts | 51 + .../tradfri/tradfri.classes.integration.ts | 98 +- ts/integrations/tradfri/tradfri.discovery.ts | 150 +++ ts/integrations/tradfri/tradfri.mapper.ts | 829 ++++++++++++ ts/integrations/tradfri/tradfri.types.ts | 296 ++++- .../wiz/.generated-by-smarthome-exchange | 1 - ts/integrations/wiz/index.ts | 4 + ts/integrations/wiz/wiz.classes.client.ts | 278 ++++ ts/integrations/wiz/wiz.classes.configflow.ts | 84 ++ .../wiz/wiz.classes.integration.ts | 103 +- ts/integrations/wiz/wiz.discovery.ts | 314 +++++ ts/integrations/wiz/wiz.mapper.ts | 764 +++++++++++ ts/integrations/wiz/wiz.types.ts | 265 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/xiaomi_miio/index.ts | 4 + .../xiaomi_miio/xiaomi_miio.classes.client.ts | 65 + .../xiaomi_miio.classes.configflow.ts | 103 ++ .../xiaomi_miio.classes.integration.ts | 99 +- .../xiaomi_miio/xiaomi_miio.discovery.ts | 217 +++ .../xiaomi_miio/xiaomi_miio.mapper.ts | 709 ++++++++++ .../xiaomi_miio/xiaomi_miio.types.ts | 208 ++- .../yeelight/.generated-by-smarthome-exchange | 1 - ts/integrations/yeelight/index.ts | 4 + .../yeelight/yeelight.classes.client.ts | 323 +++++ .../yeelight/yeelight.classes.configflow.ts | 46 + .../yeelight/yeelight.classes.integration.ts | 159 ++- .../yeelight/yeelight.discovery.ts | 170 +++ ts/integrations/yeelight/yeelight.mapper.ts | 219 +++ ts/integrations/yeelight/yeelight.types.ts | 218 ++- .../zha/.generated-by-smarthome-exchange | 1 - ts/integrations/zha/index.ts | 4 + ts/integrations/zha/zha.classes.client.ts | 77 ++ ts/integrations/zha/zha.classes.configflow.ts | 121 ++ .../zha/zha.classes.integration.ts | 157 ++- ts/integrations/zha/zha.discovery.ts | 322 +++++ ts/integrations/zha/zha.mapper.ts | 310 +++++ ts/integrations/zha/zha.types.ts | 346 ++++- 102 files changed, 16316 insertions(+), 330 deletions(-) create mode 100644 test/deconz/test.deconz.discovery.node.ts create mode 100644 test/deconz/test.deconz.mapper.node.ts create mode 100644 test/esphome/test.esphome.discovery.node.ts create mode 100644 test/esphome/test.esphome.mapper.node.ts create mode 100644 test/homekit_controller/test.homekit_controller.discovery.node.ts create mode 100644 test/homekit_controller/test.homekit_controller.mapper.node.ts create mode 100644 test/matter/test.matter.discovery.node.ts create mode 100644 test/matter/test.matter.mapper.node.ts create mode 100644 test/nanoleaf/test.nanoleaf.discovery.node.ts create mode 100644 test/nanoleaf/test.nanoleaf.mapper.node.ts create mode 100644 test/tradfri/test.tradfri.discovery.node.ts create mode 100644 test/tradfri/test.tradfri.mapper.node.ts create mode 100644 test/wiz/test.wiz.discovery.node.ts create mode 100644 test/wiz/test.wiz.mapper.node.ts create mode 100644 test/xiaomi_miio/test.xiaomi_miio.discovery.node.ts create mode 100644 test/xiaomi_miio/test.xiaomi_miio.mapper.node.ts create mode 100644 test/yeelight/test.yeelight.discovery.node.ts create mode 100644 test/yeelight/test.yeelight.mapper.node.ts create mode 100644 test/zha/test.zha.discovery.node.ts create mode 100644 test/zha/test.zha.mapper.node.ts delete mode 100644 ts/integrations/deconz/.generated-by-smarthome-exchange create mode 100644 ts/integrations/deconz/deconz.classes.client.ts create mode 100644 ts/integrations/deconz/deconz.classes.configflow.ts create mode 100644 ts/integrations/deconz/deconz.discovery.ts create mode 100644 ts/integrations/deconz/deconz.mapper.ts delete mode 100644 ts/integrations/esphome/.generated-by-smarthome-exchange create mode 100644 ts/integrations/esphome/esphome.classes.client.ts create mode 100644 ts/integrations/esphome/esphome.classes.configflow.ts create mode 100644 ts/integrations/esphome/esphome.discovery.ts create mode 100644 ts/integrations/esphome/esphome.mapper.ts delete mode 100644 ts/integrations/homekit_controller/.generated-by-smarthome-exchange create mode 100644 ts/integrations/homekit_controller/homekit_controller.classes.client.ts create mode 100644 ts/integrations/homekit_controller/homekit_controller.classes.configflow.ts create mode 100644 ts/integrations/homekit_controller/homekit_controller.discovery.ts create mode 100644 ts/integrations/homekit_controller/homekit_controller.mapper.ts delete mode 100644 ts/integrations/matter/.generated-by-smarthome-exchange create mode 100644 ts/integrations/matter/matter.classes.client.ts create mode 100644 ts/integrations/matter/matter.classes.configflow.ts create mode 100644 ts/integrations/matter/matter.discovery.ts create mode 100644 ts/integrations/matter/matter.mapper.ts delete mode 100644 ts/integrations/nanoleaf/.generated-by-smarthome-exchange create mode 100644 ts/integrations/nanoleaf/nanoleaf.classes.client.ts create mode 100644 ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts create mode 100644 ts/integrations/nanoleaf/nanoleaf.discovery.ts create mode 100644 ts/integrations/nanoleaf/nanoleaf.mapper.ts delete mode 100644 ts/integrations/tradfri/.generated-by-smarthome-exchange create mode 100644 ts/integrations/tradfri/tradfri.classes.client.ts create mode 100644 ts/integrations/tradfri/tradfri.classes.configflow.ts create mode 100644 ts/integrations/tradfri/tradfri.discovery.ts create mode 100644 ts/integrations/tradfri/tradfri.mapper.ts delete mode 100644 ts/integrations/wiz/.generated-by-smarthome-exchange create mode 100644 ts/integrations/wiz/wiz.classes.client.ts create mode 100644 ts/integrations/wiz/wiz.classes.configflow.ts create mode 100644 ts/integrations/wiz/wiz.discovery.ts create mode 100644 ts/integrations/wiz/wiz.mapper.ts delete mode 100644 ts/integrations/xiaomi_miio/.generated-by-smarthome-exchange create mode 100644 ts/integrations/xiaomi_miio/xiaomi_miio.classes.client.ts create mode 100644 ts/integrations/xiaomi_miio/xiaomi_miio.classes.configflow.ts create mode 100644 ts/integrations/xiaomi_miio/xiaomi_miio.discovery.ts create mode 100644 ts/integrations/xiaomi_miio/xiaomi_miio.mapper.ts delete mode 100644 ts/integrations/yeelight/.generated-by-smarthome-exchange create mode 100644 ts/integrations/yeelight/yeelight.classes.client.ts create mode 100644 ts/integrations/yeelight/yeelight.classes.configflow.ts create mode 100644 ts/integrations/yeelight/yeelight.discovery.ts create mode 100644 ts/integrations/yeelight/yeelight.mapper.ts delete mode 100644 ts/integrations/zha/.generated-by-smarthome-exchange create mode 100644 ts/integrations/zha/zha.classes.client.ts create mode 100644 ts/integrations/zha/zha.classes.configflow.ts create mode 100644 ts/integrations/zha/zha.discovery.ts create mode 100644 ts/integrations/zha/zha.mapper.ts diff --git a/test/deconz/test.deconz.discovery.node.ts b/test/deconz/test.deconz.discovery.node.ts new file mode 100644 index 0000000..0eddf03 --- /dev/null +++ b/test/deconz/test.deconz.discovery.node.ts @@ -0,0 +1,49 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createDeconzDiscoveryDescriptor } from '../../ts/integrations/deconz/index.js'; + +tap.test('matches deCONZ mDNS, SSDP, and manual discovery records', async () => { + const descriptor = createDeconzDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-mdns-match')!; + const ssdpMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-ssdp-match')!; + const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-manual-match')!; + const validator = descriptor.getValidators()[0]; + + const mdnsResult = await mdnsMatcher.matches({ + name: 'deCONZ-GW', + type: '_http._tcp.local.', + host: 'deconz.local', + port: 80, + txt: { + bridgeid: '00212EFFFF00C5FB', + modelid: 'deCONZ', + }, + }, {}); + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.normalizedDeviceId).toEqual('00212EFFFF00C5FB'); + + const ssdpResult = await ssdpMatcher.matches({ + ssdpLocation: 'http://192.168.1.55:80/description.xml', + upnp: { + manufacturer: 'Royal Philips Electronics', + manufacturerURL: 'http://www.dresden-elektronik.de', + modelName: 'deCONZ', + serialNumber: '00:21:2e:ff:ff:00:c5:fb', + }, + }, {}); + expect(ssdpResult.matched).toBeTrue(); + expect(ssdpResult.candidate?.host).toEqual('192.168.1.55'); + expect(ssdpResult.normalizedDeviceId).toEqual('00212EFFFF00C5FB'); + + const manualResult = await manualMatcher.matches({ + host: '192.168.1.56', + name: 'Phoscon Gateway', + model: 'ConBee II', + }, {}); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.port).toEqual(80); + + const validationResult = await validator.validate(manualResult.candidate!, {}); + expect(validationResult.matched).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/deconz/test.deconz.mapper.node.ts b/test/deconz/test.deconz.mapper.node.ts new file mode 100644 index 0000000..4f774b8 --- /dev/null +++ b/test/deconz/test.deconz.mapper.node.ts @@ -0,0 +1,136 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DeconzMapper, type IDeconzSnapshot } from '../../ts/integrations/deconz/index.js'; + +const snapshot: IDeconzSnapshot = { + config: { + bridgeid: '00212EFFFF00C5FB', + name: 'RaspBee GW', + devicename: 'ConBee II', + modelid: 'deCONZ', + swversion: '2.26.3', + rfconnected: true, + websocketport: 8088, + }, + lights: { + '1': { + name: 'Kitchen Ceiling', + manufacturername: 'IKEA', + modelid: 'TRADFRI bulb E27', + type: 'Extended color light', + uniqueid: '00:0b:57:ff:fe:9a:46:ab-01', + state: { + on: true, + bri: 204, + ct: 370, + reachable: true, + }, + }, + '2': { + name: 'Counter Plug', + manufacturername: 'dresden elektronik', + modelid: 'Smart plug', + type: 'Smart plug', + uniqueid: '00:0b:57:ff:fe:9a:46:ac-01', + state: { + on: false, + reachable: true, + }, + }, + '3': { + name: 'Living Blind', + manufacturername: 'ubisys', + modelid: 'J1', + type: 'Window covering device', + uniqueid: '00:0b:57:ff:fe:9a:46:ad-01', + state: { + open: true, + lift: 25, + reachable: true, + }, + }, + }, + groups: { + '4': { + name: 'Living Room', + lights: ['1', '3'], + state: { + all_on: false, + any_on: true, + }, + action: { + on: true, + bri: 128, + }, + }, + }, + sensors: { + '5': { + name: 'Hall Temperature', + manufacturername: 'Xiaomi', + modelid: 'lumi.weather', + type: 'ZHATemperature', + uniqueid: '00:15:8d:00:01:aa:bb:cc-01-0402', + config: { + battery: 88, + on: true, + reachable: true, + }, + state: { + temperature: 2150, + lastupdated: '2026-05-05T08:00:00', + }, + }, + '6': { + name: 'Hall Motion', + manufacturername: 'Philips', + modelid: 'SML001', + type: 'ZHAPresence', + uniqueid: '00:17:88:01:02:03:04:05-02-0406', + config: { + lowbattery: false, + on: true, + reachable: true, + }, + state: { + presence: true, + lastupdated: '2026-05-05T08:01:00', + }, + }, + '7': { + name: 'Radiator', + manufacturername: 'Eurotronic', + modelid: 'SPZB0001', + type: 'ZHAThermostat', + uniqueid: '00:15:8d:00:01:aa:bb:dd-01-0201', + config: { + heatsetpoint: 2200, + locked: false, + mode: 'heat', + on: true, + reachable: true, + }, + state: { + temperature: 2010, + lastupdated: '2026-05-05T08:02:00', + }, + }, + }, +}; + +tap.test('maps deCONZ lights, groups, sensors, covers, and climate entities', async () => { + const devices = DeconzMapper.toDevices(snapshot); + const entities = DeconzMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'deconz.gateway.00212effff00c5fb')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'cover'))).toBeTrue(); + + expect(entities.find((entityArg) => entityArg.id === 'light.kitchen_ceiling')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.counter_plug')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'cover.living_blind')?.attributes?.position).toEqual(75); + expect(entities.find((entityArg) => entityArg.id === 'light.living_room')?.attributes?.isDeconzGroup).toEqual(true); + expect(entities.find((entityArg) => entityArg.id === 'sensor.hall_temperature')?.state).toEqual(21.5); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.hall_motion')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'climate.radiator')?.attributes?.targetTemperature).toEqual(22); +}); + +export default tap.start(); diff --git a/test/esphome/test.esphome.discovery.node.ts b/test/esphome/test.esphome.discovery.node.ts new file mode 100644 index 0000000..63d3385 --- /dev/null +++ b/test/esphome/test.esphome.discovery.node.ts @@ -0,0 +1,47 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createEsphomeDiscoveryDescriptor } from '../../ts/integrations/esphome/index.js'; + +tap.test('matches ESPHome native API mDNS records', async () => { + const descriptor = createEsphomeDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_esphomelib._tcp.local.', + name: 'kitchen_sensor._esphomelib._tcp.local.', + host: 'kitchen-sensor.local', + port: 6053, + txt: { + mac: 'aabbccddeeff', + name: 'kitchen_sensor', + friendly_name: 'Kitchen Sensor', + api_encryption: 'Noise_NNpsk0_25519_ChaChaPoly_SHA256', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff'); + expect(result.candidate?.host).toEqual('kitchen-sensor.local'); + expect(result.candidate?.port).toEqual(6053); + expect(result.candidate?.metadata?.encryptionRequired).toBeTrue(); +}); + +tap.test('matches new ESPHome mDNS service type and manual entries', async () => { + const descriptor = createEsphomeDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const manualMatcher = descriptor.getMatchers()[1]; + const mdnsResult = await mdnsMatcher.matches({ + type: '_esphome._tcp.local.', + name: 'garage_door._esphome._tcp.local.', + hostname: 'garage-door.local.', + port: 6053, + properties: { friendly_name: 'Garage Door' }, + }, {}); + const manualResult = await manualMatcher.matches({ + host: 'garage-door.local', + name: 'Garage Door', + }, {}); + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.candidate?.name).toEqual('Garage Door'); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.port).toEqual(6053); +}); + +export default tap.start(); diff --git a/test/esphome/test.esphome.mapper.node.ts b/test/esphome/test.esphome.mapper.node.ts new file mode 100644 index 0000000..9a9b161 --- /dev/null +++ b/test/esphome/test.esphome.mapper.node.ts @@ -0,0 +1,104 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { EsphomeMapper } from '../../ts/integrations/esphome/index.js'; + +const snapshot = EsphomeMapper.toSnapshot({ + host: 'living-room-node.local', + deviceInfo: { + name: 'living_room_node', + friendlyName: 'Living Room Node', + macAddress: 'AA:BB:CC:DD:EE:FF', + manufacturer: 'Espressif', + model: 'ESP32', + esphomeVersion: '2026.4.4', + }, + entities: [ + { platform: 'light', key: 1, name: 'Lamp', objectId: 'lamp' }, + { platform: 'switch', key: 2, name: 'Relay', objectId: 'relay' }, + { platform: 'sensor', key: 3, name: 'Temperature', objectId: 'temperature', unitOfMeasurement: 'C', deviceClass: 'temperature' }, + { platform: 'binary_sensor', key: 4, name: 'Motion', objectId: 'motion', deviceClass: 'motion' }, + { platform: 'fan', key: 5, name: 'Fan', objectId: 'fan' }, + { platform: 'cover', key: 6, name: 'Blind', objectId: 'blind', supportsPosition: true }, + { platform: 'climate', key: 7, name: 'Thermostat', objectId: 'thermostat' }, + { platform: 'button', key: 8, name: 'Restart', objectId: 'restart' }, + { platform: 'number', key: 9, name: 'Target humidity', objectId: 'target_humidity', minValue: 0, maxValue: 100, step: 1 }, + { platform: 'select', key: 10, name: 'Mode', objectId: 'mode', options: ['Auto', 'Manual'] }, + ], + states: [ + { platform: 'light', key: 1, state: true, brightness: 0.5 }, + { platform: 'switch', key: 2, state: false }, + { platform: 'sensor', key: 3, state: 21.25 }, + { platform: 'binary_sensor', key: 4, state: true }, + { platform: 'fan', key: 5, state: true, percentage: 66 }, + { platform: 'cover', key: 6, position: 0.42 }, + { platform: 'climate', key: 7, mode: 'heat', current_temperature: 20.5, target_temperature: 22 }, + { platform: 'number', key: 9, state: 45 }, + { platform: 'select', key: 10, state: 'Auto' }, + ], +}); + +tap.test('maps ESPHome snapshot devices and entities', async () => { + const devices = EsphomeMapper.toDevices(snapshot); + const entities = EsphomeMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'esphome.device.aabbccddeeff')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.state === 'off')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 21.25)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 42)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'select' && entityArg.state === 'Auto')).toBeTrue(); +}); + +tap.test('maps entity services to safe ESPHome command shapes', async () => { + const deviceId = EsphomeMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'number')?.deviceId; + const numberCommand = EsphomeMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { entityId: 'number.living_room_node_target_humidity' }, + data: { value: 55 }, + }); + const coverCommand = EsphomeMapper.commandForService(snapshot, { + domain: 'cover', + service: 'set_position', + target: { entityId: 'cover.living_room_node_blind' }, + data: { position: 70 }, + }); + const selectCommand = EsphomeMapper.commandForService(snapshot, { + domain: 'select', + service: 'select_option', + target: { entityId: 'select.living_room_node_mode' }, + data: { option: 'Manual' }, + }); + expect(numberCommand?.payload.value).toEqual(55); + expect(coverCommand?.payload.position).toEqual(0.7); + expect(selectCommand?.payload.option).toEqual('Manual'); + if (!deviceId) { + throw new Error('Expected mapped number entity device id'); + } + const deviceTargetNumberCommand = EsphomeMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { deviceId }, + data: { value: 60 }, + }); + expect(deviceTargetNumberCommand?.platform).toEqual('number'); + expect(deviceTargetNumberCommand?.payload.value).toEqual(60); +}); + +tap.test('uses manual entry data for snapshots', async () => { + const manualSnapshot = EsphomeMapper.toSnapshot({ + manualEntries: [{ + host: 'manual-node.local', + port: 6053, + name: 'Manual Node', + deviceName: 'manual_node', + manufacturer: 'ESPHome', + model: 'ESP32-S3', + encryptionKey: 'base64-key', + }], + }); + expect(manualSnapshot.host).toEqual('manual-node.local'); + expect(manualSnapshot.deviceInfo.name).toEqual('manual_node'); + expect(manualSnapshot.deviceInfo.friendlyName).toEqual('Manual Node'); + expect(manualSnapshot.deviceInfo.apiEncryptionSupported).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/homekit_controller/test.homekit_controller.discovery.node.ts b/test/homekit_controller/test.homekit_controller.discovery.node.ts new file mode 100644 index 0000000..314f637 --- /dev/null +++ b/test/homekit_controller/test.homekit_controller.discovery.node.ts @@ -0,0 +1,83 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomekitControllerConfigFlow, createHomekitControllerDiscoveryDescriptor } from '../../ts/integrations/homekit_controller/index.js'; + +tap.test('matches HomeKit mDNS records', async () => { + const descriptor = createHomekitControllerDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_hap._tcp.local.', + name: 'Desk Lamp._hap._tcp.local.', + host: 'desk-lamp.local', + port: 51826, + txt: { + id: 'AA:BB:CC:DD:EE:FF', + md: 'Lamp 1', + mf: 'Example Lighting', + ci: '5', + sf: '1', + 'c#': '2', + 's#': '1', + pv: '1.1', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff'); + expect(result.candidate?.host).toEqual('desk-lamp.local'); + expect(result.candidate?.metadata?.categoryName).toEqual('Lightbulb'); + expect(result.candidate?.metadata?.paired).toEqual(false); +}); + +tap.test('validates HomeKit candidates by metadata', async () => { + const descriptor = createHomekitControllerDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + id: 'AA:BB:CC:DD:EE:FF', + host: 'desk-lamp.local', + metadata: { homekit: true, category: 5 }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); +}); + +tap.test('preserves manual HomeKit setup metadata for config flow', async () => { + const descriptor = createHomekitControllerDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + source: 'manual', + id: 'AA:BB:CC:DD:EE:FF', + name: 'Manual Lamp', + host: 'manual-lamp.local', + setupCode: '23456789', + category: 5, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.metadata?.setupCode).toEqual('23456789'); + + const flow = new HomekitControllerConfigFlow(); + const step = await flow.start(result.candidate!, {}); + const done = await step.submit?.({}); + + expect(done?.kind).toEqual('done'); + expect(done?.config?.setupCode).toEqual('234-56-789'); + expect(done?.config?.host).toEqual('manual-lamp.local'); + expect(done?.config?.id).toEqual('aa:bb:cc:dd:ee:ff'); +}); + +tap.test('rejects generic candidates with non-HomeKit category metadata', async () => { + const descriptor = createHomekitControllerDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + id: 'generic-device', + host: 'generic.local', + metadata: { category: 5 }, + }, {}); + + expect(result.matched).toEqual(false); +}); + +export default tap.start(); diff --git a/test/homekit_controller/test.homekit_controller.mapper.node.ts b/test/homekit_controller/test.homekit_controller.mapper.node.ts new file mode 100644 index 0000000..d7370ee --- /dev/null +++ b/test/homekit_controller/test.homekit_controller.mapper.node.ts @@ -0,0 +1,176 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomekitControllerIntegration, HomekitControllerMapper } from '../../ts/integrations/homekit_controller/index.js'; + +const snapshot = HomekitControllerMapper.toSnapshot({ + id: 'aa:bb:cc:dd:ee:ff', + connected: true, + accessories: [{ + aid: 1, + services: [{ + iid: 1, + type: '0000003E-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 2, type: '00000023-0000-1000-8000-0026BB765291', value: 'Living Room Bridge' }, + { iid: 3, type: '00000020-0000-1000-8000-0026BB765291', value: 'Example' }, + { iid: 4, type: '00000021-0000-1000-8000-0026BB765291', value: 'HK-Bridge' }, + { iid: 5, type: '00000030-0000-1000-8000-0026BB765291', value: 'SERIAL1' }, + ], + }, { + iid: 10, + type: '00000043-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 11, type: '00000023-0000-1000-8000-0026BB765291', value: 'Lamp' }, + { iid: 12, type: '00000025-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'pw', 'ev'] }, + { iid: 13, type: '00000008-0000-1000-8000-0026BB765291', value: 42, perms: ['pr', 'pw', 'ev'] }, + ], + }, { + iid: 20, + type: '00000045-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 21, type: '0000001D-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'ev'] }, + { iid: 22, type: '0000001E-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] }, + ], + }, { + iid: 30, + type: '0000008C-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 31, type: '0000006D-0000-1000-8000-0026BB765291', value: 50, perms: ['pr', 'ev'] }, + { iid: 32, type: '0000007C-0000-1000-8000-0026BB765291', value: 50, perms: ['pr', 'pw', 'ev'] }, + { iid: 33, type: '00000072-0000-1000-8000-0026BB765291', value: 2, perms: ['pr', 'ev'] }, + ], + }, { + iid: 40, + type: '00000110-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 41, type: '000000B0-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] }, + ], + }, { + iid: 50, + type: '00000049-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 51, type: '00000023-0000-1000-8000-0026BB765291', value: 'Wall Switch' }, + { iid: 52, type: '00000025-0000-1000-8000-0026BB765291', value: false, perms: ['pr', 'pw', 'ev'] }, + ], + }, { + iid: 60, + type: '00000047-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 61, type: '00000023-0000-1000-8000-0026BB765291', value: 'Outlet' }, + { iid: 62, type: '00000025-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'pw', 'ev'] }, + { iid: 63, type: '00000026-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'ev'] }, + ], + }, { + iid: 70, + type: '0000008A-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 71, type: '00000011-0000-1000-8000-0026BB765291', value: 21.5, perms: ['pr', 'ev'] }, + ], + }, { + iid: 80, + type: '0000004A-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 81, type: '00000011-0000-1000-8000-0026BB765291', value: 20, perms: ['pr', 'ev'] }, + { iid: 82, type: '00000035-0000-1000-8000-0026BB765291', value: 23, perms: ['pr', 'pw', 'ev'] }, + { iid: 83, type: '00000033-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] }, + ], + }, { + iid: 90, + type: '00000041-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 91, type: '0000000E-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'ev'] }, + { iid: 92, type: '00000032-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] }, + { iid: 93, type: '00000024-0000-1000-8000-0026BB765291', value: false, perms: ['pr', 'ev'] }, + ], + }, { + iid: 100, + type: '000000B7-0000-1000-8000-0026BB765291', + characteristics: [ + { iid: 101, type: '000000B0-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] }, + { iid: 102, type: '00000029-0000-1000-8000-0026BB765291', value: 55, perms: ['pr', 'pw', 'ev'] }, + ], + }], + }], +}); + +tap.test('maps HomeKit accessories to devices and entities', async () => { + const devices = HomekitControllerMapper.toDevices(snapshot); + const entities = HomekitControllerMapper.toEntities(snapshot); + + expect(devices[0].protocol).toEqual('homekit'); + expect(devices[0].manufacturer).toEqual('Example'); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on' && entityArg.attributes?.brightness === 42)).toBeTrue(); + expect(entities.some((entityArg) => String(entityArg.platform) === 'lock' && entityArg.state === 'locked')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 'open')).toBeTrue(); + expect(entities.some((entityArg) => String(entityArg.platform) === 'camera' && entityArg.state === 'available')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.attributes?.serviceType === 'outlet' && entityArg.attributes?.outletInUse === true)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 21.5)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'heat' && entityArg.attributes?.targetTemperature === 23)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 'closed' && entityArg.attributes?.serviceType === 'garage_door_opener')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'fan' && entityArg.state === 'on' && entityArg.attributes?.percentage === 55)).toBeTrue(); +}); + +tap.test('maps common entity services to HomeKit writes', async () => { + const light = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'light'); + const command = HomekitControllerMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_off', + target: { entityId: light?.id }, + }); + + expect(command?.command).toEqual('write_characteristics'); + expect(command?.writes?.[0]?.aid).toEqual(1); + expect(command?.writes?.[0]?.iid).toEqual(12); + expect(command?.writes?.[0]?.value).toEqual(false); + + const lightOnCommand = HomekitControllerMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: light?.id }, + data: { brightness: 128 }, + }); + expect(lightOnCommand?.writes?.some((writeArg) => writeArg.iid === 13 && writeArg.value === 50)).toBeTrue(); + + const fan = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'fan'); + const fanCommand = HomekitControllerMapper.commandForService(snapshot, { + domain: 'fan', + service: 'set_percentage', + target: { entityId: fan?.id }, + data: { percentage: 75 }, + }); + expect(fanCommand?.writes?.some((writeArg) => writeArg.iid === 102 && writeArg.value === 75)).toBeTrue(); + + const climate = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'climate'); + const climateCommand = HomekitControllerMapper.commandForService(snapshot, { + domain: 'climate', + service: 'set_hvac_mode', + target: { entityId: climate?.id }, + data: { hvac_mode: 'heat_cool' }, + }); + expect(climateCommand?.writes?.some((writeArg) => writeArg.iid === 83 && writeArg.value === 3)).toBeTrue(); + + const camera = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => String(entityArg.platform) === 'camera'); + const cameraCommand = HomekitControllerMapper.commandForService(snapshot, { + domain: 'camera', + service: 'snapshot', + target: { entityId: camera?.id }, + data: { width: 320, height: 240 }, + }); + expect(cameraCommand?.command).toEqual('camera_snapshot'); + expect(cameraCommand?.aid).toEqual(1); +}); + +tap.test('returns explicit unsupported errors for native HAP security operations', async () => { + const integration = new HomekitControllerIntegration(); + const runtime = await integration.setup({ host: 'desk-lamp.local', setupCode: '234-56-789' }, {}); + const result = await runtime.callService?.({ + domain: 'homekit_controller', + service: 'pair_setup', + target: {}, + }); + + expect(result?.success).toEqual(false); + expect(String(result?.error).includes('pair setup is not implemented')).toBeTrue(); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/matter/test.matter.discovery.node.ts b/test/matter/test.matter.discovery.node.ts new file mode 100644 index 0000000..d58e5c8 --- /dev/null +++ b/test/matter/test.matter.discovery.node.ts @@ -0,0 +1,48 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { MatterConfigFlow, createMatterDiscoveryDescriptor } from '../../ts/integrations/matter/index.js'; + +tap.test('matches Matter zeroconf records', async () => { + const descriptor = createMatterDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + + const operational = await matcher.matches({ + type: '_matter._tcp.local.', + name: 'Kitchen Bulb._matter._tcp.local.', + host: 'kitchen-bulb.local', + port: 5540, + txt: { dn: 'Kitchen Bulb' }, + }, {}); + const commissionable = await matcher.matches({ + type: '_matterc._udp.local.', + name: 'Commissionable._matterc._udp.local.', + host: 'commissionable.local', + port: 5540, + }, {}); + + expect(operational.matched).toBeTrue(); + expect(operational.candidate?.name).toEqual('Kitchen Bulb'); + expect(commissionable.matched).toBeTrue(); + expect(commissionable.candidate?.model).toEqual('Commissionable Matter device'); +}); + +tap.test('manual Matter setup defaults to local server URL', async () => { + const descriptor = createMatterDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({}, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.metadata?.url).toEqual('ws://localhost:5580/ws'); +}); + +tap.test('Matter config flow uses candidate URL default', async () => { + const flow = new MatterConfigFlow(); + const step = await flow.start({ + source: 'manual', + metadata: { url: 'ws://matter.local:5580/ws' }, + }, {}); + const done = await step.submit?.({}); + + expect(done?.config?.url).toEqual('ws://matter.local:5580/ws'); +}); + +export default tap.start(); diff --git a/test/matter/test.matter.mapper.node.ts b/test/matter/test.matter.mapper.node.ts new file mode 100644 index 0000000..34c195a --- /dev/null +++ b/test/matter/test.matter.mapper.node.ts @@ -0,0 +1,90 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { MatterMapper } from '../../ts/integrations/matter/index.js'; +import type { IMatterSnapshot } from '../../ts/integrations/matter/index.js'; + +const snapshot: IMatterSnapshot = { + connected: true, + events: [], + serverInfo: { + fabric_id: 1, + compressed_fabric_id: 2, + schema_version: 11, + min_supported_schema_version: 6, + sdk_version: '1.4.2', + wifi_credentials_set: true, + thread_credentials_set: false, + bluetooth_enabled: false, + }, + nodes: [{ + node_id: 1234, + available: true, + attributes: { + '0/29/0': [{ deviceType: 0x0016, revision: 1 }], + '0/29/1': [29, 40], + '0/40/1': 'Acme', + '0/40/2': 123, + '0/40/3': 'Matter Test Device', + '0/40/4': 456, + '0/40/5': 'Test Node', + '0/40/15': 'serial-1', + '1/29/0': [{ deviceType: 0x0101, revision: 2 }], + '1/29/1': [29, 6, 8], + '1/6/0': true, + '1/8/0': 128, + '1/8/2': 1, + '1/8/3': 254, + '2/29/0': [{ deviceType: 0x0015, revision: 1 }], + '2/29/1': [29, 69], + '2/69/0': false, + '3/29/0': [{ deviceType: 0x0202, revision: 1 }], + '3/29/1': [29, 258], + '3/258/10': 0, + '3/258/14': 2500, + '4/29/0': [{ deviceType: 0x000a, revision: 1 }], + '4/29/1': [29, 257], + '4/257/0': 1, + '5/29/0': [{ deviceType: 0x0301, revision: 1 }], + '5/29/1': [29, 513], + '5/513/0': 2150, + '5/513/18': 2000, + '5/513/28': 4, + }, + }], +}; + +tap.test('maps Matter nodes to canonical devices and entities', async () => { + const devices = MatterMapper.toDevices(snapshot); + const entities = MatterMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'matter.node.1234.endpoint.1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.attributes?.deviceClass === 'door')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.attributes?.position === 75)).toBeTrue(); + expect(entities.some((entityArg) => String(entityArg.platform) === 'lock' && entityArg.state === 'locked')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'heat')).toBeTrue(); +}); + +tap.test('maps entity services to Matter server commands', async () => { + const light = MatterMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'light'); + const cover = MatterMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'cover'); + + const turnOff = MatterMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_off', + target: { entityId: light?.id }, + }); + const setPosition = MatterMapper.commandForService(snapshot, { + domain: 'cover', + service: 'set_position', + target: { entityId: cover?.id }, + data: { position: 42 }, + }); + + expect(turnOff?.command).toEqual('device_command'); + expect(turnOff?.args?.cluster_id).toEqual(6); + expect(turnOff?.args?.command_name).toEqual('Off'); + expect(setPosition?.args?.cluster_id).toEqual(258); + expect(setPosition?.args?.payload).toEqual({ liftPercent100thsValue: 5800 }); +}); + +export default tap.start(); diff --git a/test/nanoleaf/test.nanoleaf.discovery.node.ts b/test/nanoleaf/test.nanoleaf.discovery.node.ts new file mode 100644 index 0000000..b0c6697 --- /dev/null +++ b/test/nanoleaf/test.nanoleaf.discovery.node.ts @@ -0,0 +1,54 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createNanoleafDiscoveryDescriptor } from '../../ts/integrations/nanoleaf/index.js'; + +tap.test('matches Nanoleaf mDNS zeroconf records', async () => { + const descriptor = createNanoleafDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-mdns-match'); + const result = await matcher!.matches({ + name: 'Nanoleaf Shapes ABCD', + type: '_nanoleafapi._tcp.local.', + host: 'nanoleaf-shapes.local', + port: 16021, + txt: { + id: 'NL123ABC', + md: 'NL42', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('NL123ABC'); + expect(result.candidate?.port).toEqual(16021); +}); + +tap.test('matches Nanoleaf SSDP records', async () => { + const descriptor = createNanoleafDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-ssdp-match'); + const result = await matcher!.matches({ + st: 'nanoleaf:nl42', + usn: 'uuid:nanoleaf-nl42', + headers: { + '_host': '192.168.1.55:16021', + 'nl-devicename': 'Nanoleaf Shapes ABCD', + 'nl-deviceid': 'NL123ABC', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.55'); + expect(result.candidate?.model).toEqual('NL42'); +}); + +tap.test('validates manual Nanoleaf candidates', async () => { + const descriptor = createNanoleafDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-manual-match'); + const manualResult = await matcher!.matches({ + host: '192.168.1.56', + port: 16021, + metadata: { nanoleaf: true }, + }, {}); + expect(manualResult.matched).toBeTrue(); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(manualResult.candidate!, {}); + expect(validation.matched).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/nanoleaf/test.nanoleaf.mapper.node.ts b/test/nanoleaf/test.nanoleaf.mapper.node.ts new file mode 100644 index 0000000..2d37dc8 --- /dev/null +++ b/test/nanoleaf/test.nanoleaf.mapper.node.ts @@ -0,0 +1,59 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { NanoleafMapper } from '../../ts/integrations/nanoleaf/index.js'; + +const snapshot = { + controllerInfo: { + name: 'Living Room Shapes', + serialNo: 'NL123ABC', + manufacturer: 'Nanoleaf', + model: 'NL42', + firmwareVersion: '9.6.1', + }, + state: { + on: { value: true }, + brightness: { value: 80, min: 0, max: 100 }, + hue: { value: 220, min: 0, max: 360 }, + sat: { value: 65, min: 0, max: 100 }, + ct: { value: 4000, min: 1200, max: 6500 }, + colorMode: 'effect', + }, + effects: { + select: 'Northern Lights', + effectsList: ['Northern Lights', '*Solid*'], + }, + panelLayout: { + layout: { + numPanels: 2, + sideLength: 150, + positionData: [ + { panelId: 101, x: 0, y: 0, o: 0, shapeType: 7 }, + { panelId: 102, x: 150, y: 0, o: 60, shapeType: 7 }, + ], + }, + }, + rhythm: { + rhythmConnected: true, + rhythmActive: false, + }, +}; + +tap.test('maps Nanoleaf controller and panels to canonical devices', async () => { + const devices = NanoleafMapper.toDevices(snapshot); + expect(devices[0].id).toEqual('nanoleaf.controller.nl123abc'); + expect(devices[0].features.some((featureArg) => featureArg.id === 'panel_count')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'nanoleaf.panel.nl123abc.101')).toBeTrue(); +}); + +tap.test('maps Nanoleaf light state, sensors, select, and button entities', async () => { + const entities = NanoleafMapper.toEntities(snapshot); + const light = entities.find((entityArg) => entityArg.id === 'light.living_room_shapes'); + const effect = entities.find((entityArg) => entityArg.id === 'select.living_room_shapes_effect'); + expect(light?.state).toEqual('on'); + expect(light?.attributes?.brightness).toEqual(80); + expect(effect?.state).toEqual('Northern Lights'); + expect(entities.some((entityArg) => entityArg.id === 'button.living_room_shapes_identify')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.living_room_shapes_panel_count')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.living_room_shapes_panel_101')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/tradfri/test.tradfri.discovery.node.ts b/test/tradfri/test.tradfri.discovery.node.ts new file mode 100644 index 0000000..8504d12 --- /dev/null +++ b/test/tradfri/test.tradfri.discovery.node.ts @@ -0,0 +1,37 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createTradfriDiscoveryDescriptor } from '../../ts/integrations/tradfri/index.js'; + +tap.test('matches IKEA TRADFRI HomeKit mDNS records', async () => { + const descriptor = createTradfriDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_hap._tcp.local.', + name: 'TRADFRI-Gateway._hap._tcp.local.', + host: 'tradfri-gateway.local', + port: 8080, + txt: { + md: 'TRADFRI', + id: 'AA:BB:CC:DD:EE:FF', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:FF'); + expect(result.candidate?.host).toEqual('tradfri-gateway.local'); + expect(result.candidate?.port).toEqual(5684); +}); + +tap.test('matches and validates manual host/security-code entries', async () => { + const descriptor = createTradfriDiscoveryDescriptor(); + const manualMatcher = descriptor.getMatchers()[1]; + const validator = descriptor.getValidators()[0]; + const result = await manualMatcher.matches({ + host: '192.168.1.23', + securityCode: 'abc123', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.metadata?.securityCode).toEqual('abc123'); + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/tradfri/test.tradfri.mapper.node.ts b/test/tradfri/test.tradfri.mapper.node.ts new file mode 100644 index 0000000..db8e6c6 --- /dev/null +++ b/test/tradfri/test.tradfri.mapper.node.ts @@ -0,0 +1,123 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { TradfriMapper } from '../../ts/integrations/tradfri/index.js'; +import type { ITradfriSnapshot } from '../../ts/integrations/tradfri/index.js'; + +const snapshot: ITradfriSnapshot = { + host: 'tradfri-gateway.local', + port: 5684, + connected: true, + gateway: { + id: 'gw-001', + name: 'Tradfri Gateway', + firmwareVersion: '1.19.32', + }, + devices: [ + { + id: 65537, + name: 'Kitchen Bulb', + reachable: 1, + deviceInfo: { + manufacturer: 'IKEA of Sweden', + modelNumber: 'TRADFRI bulb E27 WS opal 980lm', + firmwareVersion: '2.3.087', + }, + lightControl: [{ state: 1, dimmer: 127, colorTemp: 370 }], + }, + { + id: 65538, + name: 'Coffee Outlet', + reachable: true, + deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'TRADFRI control outlet' }, + socketControl: [{ state: 0 }], + }, + { + id: 65539, + name: 'Bedroom Blind', + reachable: true, + deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'FYRTUR block-out roller blind', batteryLevel: 88 }, + blindControl: [{ currentCoverPosition: 40 }], + }, + { + id: 65540, + name: 'Hall Motion', + reachable: true, + deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'TRADFRI motion sensor', batteryLevel: 76 }, + sensors: [{ name: 'Motion', deviceClass: 'motion', value: true, binary: true }], + }, + { + id: 65541, + name: 'Air Purifier', + reachable: true, + deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'STARKVIND air purifier' }, + airPurifierControl: [{ mode: 1, fanSpeed: 20, airQuality: 12, filterLifetimeRemaining: 1200 }], + }, + ], + groups: [ + { + id: 131073, + name: 'Kitchen Group', + state: 1, + dimmer: 200, + memberIds: [65537, 65538], + }, + ], +}; + +tap.test('maps Tradfri snapshot to canonical devices and entities', async () => { + const devices = TradfriMapper.toDevices(snapshot); + const entities = TradfriMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'tradfri.gateway.gw_001')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'tradfri.device.gw_001.65539')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_bulb' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.coffee_outlet' && entityArg.state === 'off')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'cover.bedroom_blind' && entityArg.state === 60)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_blind_battery' && entityArg.state === 88)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.hall_motion_motion' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'fan.air_purifier' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_group')).toBeTrue(); +}); + +tap.test('maps service calls to Tradfri command payloads', async () => { + const lightCommand = TradfriMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.kitchen_bulb' }, + data: { brightness: 200 }, + }); + const coverCommand = TradfriMapper.commandForService(snapshot, { + domain: 'cover', + service: 'set_position', + target: { entityId: 'cover.bedroom_blind' }, + data: { position: 70 }, + }); + const setValueCommand = TradfriMapper.commandForService(snapshot, { + domain: 'cover', + service: 'set_value', + target: { entityId: 'cover.bedroom_blind' }, + data: { value: 25 }, + }); + const fanCommand = TradfriMapper.commandForService(snapshot, { + domain: 'fan', + service: 'set_percentage', + target: { entityId: 'fan.air_purifier' }, + data: { percentage: 50 }, + }); + const openCommand = TradfriMapper.commandForService(snapshot, { + domain: 'cover', + service: 'open_cover', + target: { entityId: 'cover.bedroom_blind' }, + }); + const closeCommand = TradfriMapper.commandForService(snapshot, { + domain: 'cover', + service: 'close_cover', + target: { entityId: 'cover.bedroom_blind' }, + }); + expect(lightCommand?.coap.payload?.['3311']).toEqual([{ '5850': 1, '5851': 200 }]); + expect(coverCommand?.payload.rawPosition).toEqual(30); + expect(setValueCommand?.payload.position).toEqual(25); + expect(fanCommand?.payload.mode).toEqual(26); + expect(openCommand?.payload.rawPosition).toEqual(0); + expect(closeCommand?.payload.rawPosition).toEqual(100); +}); + +export default tap.start(); diff --git a/test/wiz/test.wiz.discovery.node.ts b/test/wiz/test.wiz.discovery.node.ts new file mode 100644 index 0000000..f5c5d9c --- /dev/null +++ b/test/wiz/test.wiz.discovery.node.ts @@ -0,0 +1,56 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createWizDiscoveryDescriptor } from '../../ts/integrations/wiz/index.js'; + +tap.test('matches WiZ mDNS, UDP, and manual discovery records', async () => { + const descriptor = createWizDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const udpMatcher = descriptor.getMatchers()[1]; + const manualMatcher = descriptor.getMatchers()[2]; + + const mdnsResult = await mdnsMatcher.matches({ + type: '_http._tcp.local.', + name: 'wiz_a8bb50a4f94d._http._tcp.local.', + host: 'wiz-a4f94d.local', + txt: { + mac: 'a8bb50a4f94d', + name: 'Desk Lamp', + }, + }, {}); + const udpResult = await udpMatcher.matches({ + host: '192.168.1.51', + response: { + method: 'registration', + result: { + mac: 'a8bb50a4f94d', + }, + }, + }, {}); + const manualResult = await manualMatcher.matches({ + host: '192.168.1.52', + name: 'Counter Plug', + deviceInfo: { + model: 'WiZ Smart Plug', + }, + }, {}); + + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.normalizedDeviceId).toEqual('a8:bb:50:a4:f9:4d'); + expect(udpResult.matched).toBeTrue(); + expect(udpResult.candidate?.metadata?.discoveryProtocol).toEqual('udp'); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.port).toEqual(38899); +}); + +tap.test('validates WiZ candidates from DHCP-style metadata', async () => { + const validator = createWizDiscoveryDescriptor().getValidators()[0]; + const result = await validator.validate({ + source: 'dhcp', + host: 'wiz_a4f94d', + macAddress: 'A8:BB:50:A4:F9:4D', + port: 38899, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('certain'); +}); + +export default tap.start(); diff --git a/test/wiz/test.wiz.mapper.node.ts b/test/wiz/test.wiz.mapper.node.ts new file mode 100644 index 0000000..f2d48fa --- /dev/null +++ b/test/wiz/test.wiz.mapper.node.ts @@ -0,0 +1,83 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { WizMapper, type IWizSnapshot } from '../../ts/integrations/wiz/index.js'; + +const snapshot: IWizSnapshot = { + connected: true, + devices: [ + { + host: '192.168.1.51', + port: 38899, + mac: 'a8bb50a4f94d', + name: 'Desk Lamp', + available: true, + deviceInfo: { + manufacturer: 'WiZ', + model: 'RGBW Tunable', + moduleName: 'ESP03_SHRGB1C_31', + fwVersion: '1.33.0', + features: { + effect: true, + power: true, + occupancy: true, + }, + }, + pilot: { + mac: 'a8bb50a4f94d', + rssi: -57, + state: true, + sceneId: 4, + temp: 3000, + dimming: 80, + r: 255, + g: 100, + b: 0, + pc: 12345, + speed: 100, + src: 'wfa1', + }, + }, + ], + events: [], +}; + +tap.test('maps WiZ lights, sensors, buttons, numbers, and selects', async () => { + const devices = WizMapper.toDevices(snapshot); + const entities = WizMapper.toEntities(snapshot); + + expect(devices[0].id).toEqual('wiz.device.a8_bb_50_a4_f9_4d'); + expect(devices[0].features.some((featureArg) => featureArg.capability === 'light')).toBeTrue(); + expect(devices[0].features.some((featureArg) => featureArg.id === 'power')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'light.desk_lamp')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.desk_lamp_power')?.state).toEqual(12.345); + expect(entities.find((entityArg) => entityArg.id === 'button.desk_lamp_button_on')?.state).toEqual('wfa1'); + expect(entities.find((entityArg) => entityArg.id === 'select.desk_lamp_effect')?.state).toEqual('Party'); + expect(entities.find((entityArg) => entityArg.id === 'number.desk_lamp_effect_speed')?.state).toEqual(100); +}); + +tap.test('maps canonical services to WiZ setPilot payloads', async () => { + const turnOnCommand = WizMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.desk_lamp' }, + data: { brightness_pct: 50, effect: 'Ocean' }, + }); + const selectCommand = WizMapper.commandForService(snapshot, { + domain: 'select', + service: 'select_option', + target: { entityId: 'select.desk_lamp_effect' }, + data: { option: 'Party' }, + }); + const numberCommand = WizMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { entityId: 'number.desk_lamp_effect_speed' }, + data: { value: 120 }, + }); + + expect(turnOnCommand?.payload.dimming).toEqual(50); + expect(turnOnCommand?.payload.sceneId).toEqual(1); + expect(selectCommand?.payload.sceneId).toEqual(4); + expect(numberCommand?.payload.speed).toEqual(120); +}); + +export default tap.start(); diff --git a/test/xiaomi_miio/test.xiaomi_miio.discovery.node.ts b/test/xiaomi_miio/test.xiaomi_miio.discovery.node.ts new file mode 100644 index 0000000..b9f65c4 --- /dev/null +++ b/test/xiaomi_miio/test.xiaomi_miio.discovery.node.ts @@ -0,0 +1,51 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createXiaomiMiioDiscoveryDescriptor } from '../../ts/integrations/xiaomi_miio/index.js'; + +tap.test('matches Xiaomi Miio mDNS records', async () => { + const descriptor = createXiaomiMiioDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-mdns-match'); + const result = await matcher!.matches({ + type: '_miio._udp.local.', + name: 'rockrobo-vacuum-v1_miio._udp.local.', + host: '192.168.1.50', + port: 54321, + txt: { poch: 'mac=286c0789abcd', did: '123456789' }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('xiaomi_miio'); + expect(result.candidate?.model).toEqual('rockrobo.vacuum.v1'); + expect(result.candidate?.macAddress).toEqual('28:6c:07:89:ab:cd'); +}); + +tap.test('matches manual host token entries and validates candidates', async () => { + const descriptor = createXiaomiMiioDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-manual-match'); + const validator = descriptor.getValidators()[0]; + const result = await matcher!.matches({ + host: '192.168.1.51', + token: '00112233445566778899aabbccddeeff', + model: 'zhimi.airpurifier.v2', + name: 'Bedroom purifier', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.metadata?.tokenConfigured).toBeTrue(); + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.confidence).toEqual('certain'); +}); + +tap.test('matches Xiaomi Miio DHCP records', async () => { + const descriptor = createXiaomiMiioDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-dhcp-match'); + const result = await matcher!.matches({ + ipAddress: '192.168.1.52', + hostname: 'roborock-vacuum', + manufacturer: 'Xiaomi', + macAddress: '28:6C:07:89:AB:CE', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.52'); + expect(result.candidate?.macAddress).toEqual('28:6c:07:89:ab:ce'); +}); + +export default tap.start(); diff --git a/test/xiaomi_miio/test.xiaomi_miio.mapper.node.ts b/test/xiaomi_miio/test.xiaomi_miio.mapper.node.ts new file mode 100644 index 0000000..1b0b5c1 --- /dev/null +++ b/test/xiaomi_miio/test.xiaomi_miio.mapper.node.ts @@ -0,0 +1,77 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { XiaomiMiioMapper } from '../../ts/integrations/xiaomi_miio/index.js'; + +const snapshot = XiaomiMiioMapper.toSnapshot({ + devices: [ + { + id: 'vacuum-1', + name: 'Roborock S5', + model: 'rockrobo.vacuum.v1', + state: { state_code: 5, battery: 82, clean_area: 22, clean_time: 1800, fan_speed: 'Balanced' }, + }, + { + id: 'fan-1', + name: 'Bedroom purifier', + model: 'zhimi.airpurifier.v2', + state: { is_on: true, mode: 'auto', fan_level: 2, temperature: 22.4, humidity: 44, pm25: 6 }, + }, + { + id: 'light-1', + name: 'Desk lamp', + model: 'philips.light.bulb', + state: { is_on: true, brightness: 80, color_temperature: 45 }, + }, + { + id: 'plug-1', + name: 'Coffee plug', + model: 'chuangmi.plug.v3', + state: { is_on: false, temperature: 30, load_power: 0 }, + }, + { + id: 'cover-1', + name: 'Living room curtain', + kind: 'cover', + state: { position: 55 }, + }, + { + id: 'humidifier-1', + name: 'Nursery humidifier', + model: 'zhimi.humidifier.ca4', + state: { is_on: true, humidity: 41, target_humidity: 50, mode: 'Auto' }, + }, + ], +}); + +tap.test('maps Xiaomi Miio device states to canonical devices and entities', async () => { + const devices = XiaomiMiioMapper.toDevices(snapshot); + const entities = XiaomiMiioMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'xiaomi_miio.device.vacuum_1')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'vacuum.roborock_s5' && entityArg.state === 'cleaning')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'fan.bedroom_purifier' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.desk_lamp' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.coffee_plug' && entityArg.state === 'off')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'cover.living_room_curtain' && entityArg.state === 'open')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'humidifier.nursery_humidifier' && entityArg.platform === 'climate')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_purifier_pm25' && entityArg.state === 6)).toBeTrue(); +}); + +tap.test('maps vacuum and generic set_value service calls to client commands', async () => { + const vacuumCommand = XiaomiMiioMapper.commandForService(snapshot, { + domain: 'vacuum', + service: 'return_home', + target: { entityId: 'vacuum.roborock_s5' }, + }); + expect(vacuumCommand?.method).toEqual('return_home'); + expect(vacuumCommand?.kind).toEqual('vacuum'); + + const valueCommand = XiaomiMiioMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { entityId: 'number.bedroom_purifier_fan_level' }, + data: { value: 3 }, + }); + expect(valueCommand?.method).toEqual('set_value'); + expect(valueCommand?.payload.value).toEqual(3); +}); + +export default tap.start(); diff --git a/test/yeelight/test.yeelight.discovery.node.ts b/test/yeelight/test.yeelight.discovery.node.ts new file mode 100644 index 0000000..d40a123 --- /dev/null +++ b/test/yeelight/test.yeelight.discovery.node.ts @@ -0,0 +1,36 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createYeelightDiscoveryDescriptor } from '../../ts/integrations/yeelight/index.js'; + +tap.test('matches Yeelight SSDP wifi_bulb responses', async () => { + const descriptor = createYeelightDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + location: 'yeelight://192.168.1.25:55443', + id: '0x0000000002eb9f61', + model: 'color', + support: 'get_prop set_power set_bright set_rgb set_ct_abx', + st: 'wifi_bulb', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('0x0000000002eb9f61'); + expect(result.candidate?.host).toEqual('192.168.1.25'); + expect(result.candidate?.port).toEqual(55443); +}); + +tap.test('matches Yeelight mDNS records', async () => { + const descriptor = createYeelightDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + name: 'yeelink-light-color1_mibt1234', + type: '_miio._udp.local.', + host: 'yeelight-kitchen.local', + txt: { + id: '0x0000000002eb9f62', + model: 'YLDP02YL', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.manufacturer).toEqual('Yeelight'); +}); + +export default tap.start(); diff --git a/test/yeelight/test.yeelight.mapper.node.ts b/test/yeelight/test.yeelight.mapper.node.ts new file mode 100644 index 0000000..4a38e9b --- /dev/null +++ b/test/yeelight/test.yeelight.mapper.node.ts @@ -0,0 +1,40 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { YeelightMapper } from '../../ts/integrations/yeelight/index.js'; + +const snapshot = { + connected: true, + bulbs: [ + { + id: '0x0000000002eb9f61', + host: '192.168.1.25', + port: 55443, + name: 'Kitchen Bulb', + model: 'color', + support: ['get_prop', 'set_power', 'set_bright', 'set_rgb', 'set_ct_abx'], + available: true, + properties: { + power: 'on', + bright: '80', + ct: '3700', + rgb: '16711680', + hue: '0', + sat: '100', + color_mode: '1', + flowing: '0', + nl_br: '0', + }, + }, + ], + events: [], +}; + +tap.test('maps Yeelight bulbs to canonical devices and light/sensor entities', async () => { + const devices = YeelightMapper.toDevices(snapshot); + const entities = YeelightMapper.toEntities(snapshot); + expect(devices[0].id).toEqual('yeelight.bulb.0x0000000002eb9f61'); + expect(devices[0].features.some((featureArg) => featureArg.id === 'color_temperature')).toBeTrue(); + expect(entities[0].id).toEqual('light.kitchen_bulb'); + expect(entities.some((entityArg) => entityArg.id === 'sensor.kitchen_bulb_color_temperature')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/zha/test.zha.discovery.node.ts b/test/zha/test.zha.discovery.node.ts new file mode 100644 index 0000000..08a11fe --- /dev/null +++ b/test/zha/test.zha.discovery.node.ts @@ -0,0 +1,19 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createZhaDiscoveryDescriptor } from '../../ts/integrations/zha/index.js'; + +tap.test('matches known ZHA USB coordinator records', async () => { + const descriptor = createZhaDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'zha-usb-match'); + const result = await matcher!.matches({ + vid: '10C4', + pid: 'EA60', + manufacturer: 'SONOFF', + description: 'SONOFF Zigbee 3.0 USB Dongle Plus', + path: '/dev/ttyUSB0', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('zha'); + expect(result.candidate?.metadata?.radioPath).toEqual('/dev/ttyUSB0'); +}); + +export default tap.start(); diff --git a/test/zha/test.zha.mapper.node.ts b/test/zha/test.zha.mapper.node.ts new file mode 100644 index 0000000..af77af9 --- /dev/null +++ b/test/zha/test.zha.mapper.node.ts @@ -0,0 +1,32 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ZhaMapper } from '../../ts/integrations/zha/index.js'; + +const snapshot = ZhaMapper.toSnapshot({ + radio: { path: '/dev/ttyUSB0', radioType: 'znp' }, + coordinator: { ieee: '00:11:22:33:44:55:66:77', model: 'CC2652' }, + devices: [{ + ieee: 'aa:bb:cc:dd:ee:ff:00:11', + name: 'Kitchen light', + manufacturer: 'IKEA', + model: 'TRADFRI bulb', + available: true, + entities: [{ + platform: 'light', + entityId: 'light.kitchen_light', + uniqueId: 'zha_kitchen_light', + name: 'Kitchen light', + isOn: true, + endpointId: 1, + clusterId: 6, + }], + }], +}); + +tap.test('maps ZHA devices and entities', async () => { + const devices = ZhaMapper.toDevices(snapshot); + const entities = ZhaMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'zha.device.aa_bb_cc_dd_ee_ff_00_11')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_light' && entityArg.state === 'on')).toBeTrue(); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 086f18a..bdcff9d 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,23 +4,43 @@ export * from './integrations/index.js'; import { HueIntegration } from './integrations/hue/index.js'; import { CastIntegration } from './integrations/cast/index.js'; +import { DeconzIntegration } from './integrations/deconz/index.js'; +import { EsphomeIntegration } from './integrations/esphome/index.js'; +import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; +import { MatterIntegration } from './integrations/matter/index.js'; import { MqttIntegration } from './integrations/mqtt/index.js'; +import { NanoleafIntegration } from './integrations/nanoleaf/index.js'; import { RokuIntegration } from './integrations/roku/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js'; import { SonosIntegration } from './integrations/sonos/index.js'; +import { TradfriIntegration } from './integrations/tradfri/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; +import { WizIntegration } from './integrations/wiz/index.js'; +import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js'; +import { YeelightIntegration } from './integrations/yeelight/index.js'; +import { ZhaIntegration } from './integrations/zha/index.js'; import { ZwaveJsIntegration } from './integrations/zwave_js/index.js'; import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js'; import { IntegrationRegistry } from './core/index.js'; export const integrations = [ new CastIntegration(), + new DeconzIntegration(), + new EsphomeIntegration(), + new HomekitControllerIntegration(), new HueIntegration(), + new MatterIntegration(), new MqttIntegration(), + new NanoleafIntegration(), new RokuIntegration(), new ShellyIntegration(), new SonosIntegration(), + new TradfriIntegration(), new WolfSmartsetIntegration(), + new WizIntegration(), + new XiaomiMiioIntegration(), + new YeelightIntegration(), + new ZhaIntegration(), new ZwaveJsIntegration(), ]; diff --git a/ts/integrations/deconz/.generated-by-smarthome-exchange b/ts/integrations/deconz/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/deconz/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/deconz/deconz.classes.client.ts b/ts/integrations/deconz/deconz.classes.client.ts new file mode 100644 index 0000000..8a844c0 --- /dev/null +++ b/ts/integrations/deconz/deconz.classes.client.ts @@ -0,0 +1,238 @@ +import type { + IDeconzConfig, + IDeconzGatewayConfig, + IDeconzGroupActionPatch, + IDeconzLightStatePatch, + IDeconzSensorConfigPatch, + IDeconzSensorStatePatch, + IDeconzSnapshot, + IDeconzWebsocketEvent, +} from './deconz.types.js'; + +type TWebSocketMessage = { data: unknown }; + +type TWebSocketLike = { + close(): void; + addEventListener?: (eventArg: 'message', handlerArg: (messageArg: TWebSocketMessage) => void) => void; + onmessage?: (messageArg: TWebSocketMessage) => void; +}; + +type TWebSocketConstructor = new (urlArg: string) => TWebSocketLike; + +export class DeconzClient { + constructor(private readonly config: IDeconzConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.config.snapshot; + } + return this.requestJson('GET', ''); + } + + public async getGatewayConfig(): Promise { + if (this.config.snapshot?.config) { + return this.config.snapshot.config; + } + return this.requestJson('GET', '/config'); + } + + public async setLightState(lightIdArg: string, stateArg: IDeconzLightStatePatch): Promise { + if (this.canUseHttp()) { + await this.put(`/lights/${encodeURIComponent(lightIdArg)}/state`, stateArg); + } + this.applyLightState(lightIdArg, stateArg); + } + + public async setGroupState(groupIdArg: string, actionArg: IDeconzGroupActionPatch): Promise { + if (this.canUseHttp()) { + await this.put(`/groups/${encodeURIComponent(groupIdArg)}/action`, actionArg); + } + this.applyGroupState(groupIdArg, actionArg); + } + + public async setSensorConfig(sensorIdArg: string, configArg: IDeconzSensorConfigPatch): Promise { + if (this.canUseHttp()) { + await this.put(`/sensors/${encodeURIComponent(sensorIdArg)}/config`, configArg); + } + this.applySensorConfig(sensorIdArg, configArg); + } + + public async setSensorState(sensorIdArg: string, stateArg: IDeconzSensorStatePatch): Promise { + if (this.canUseHttp()) { + await this.put(`/sensors/${encodeURIComponent(sensorIdArg)}/state`, stateArg); + } + this.applySensorState(sensorIdArg, stateArg); + } + + public async recallScene(groupIdArg: string, sceneIdArg: string): Promise { + if (this.canUseHttp()) { + await this.put(`/groups/${encodeURIComponent(groupIdArg)}/scenes/${encodeURIComponent(sceneIdArg)}/recall`, {}); + } + const group = this.config.snapshot?.groups[groupIdArg]; + if (group) { + group.action = { ...group.action, scene: sceneIdArg }; + } + } + + public async put(pathArg: string, dataArg: Record): Promise { + return this.requestJson('PUT', pathArg, dataArg); + } + + public async post(pathArg: string, dataArg: Record): Promise { + return this.requestJson('POST', pathArg, dataArg); + } + + public async subscribeToEvents(handlerArg: (eventArg: IDeconzWebsocketEvent) => void): Promise<() => Promise> { + if (this.config.enableWebSocket === false || !this.config.host) { + return async () => {}; + } + + const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: TWebSocketConstructor }).WebSocket; + if (!WebSocketCtor) { + return async () => {}; + } + + const websocketPort = await this.resolveWebSocketPort(); + if (!websocketPort) { + return async () => {}; + } + + const socket = new WebSocketCtor(`ws://${this.config.host}:${websocketPort}`); + const listener = (messageArg: TWebSocketMessage) => { + const event = this.parseWebSocketMessage(messageArg.data); + if (event) { + handlerArg(event); + } + }; + + if (socket.addEventListener) { + socket.addEventListener('message', listener); + } else { + socket.onmessage = listener; + } + + return async () => { + socket.close(); + }; + } + + public async destroy(): Promise {} + + private async requestJson(methodArg: string, pathArg: string, dataArg?: Record): Promise { + if (!this.canUseHttp()) { + throw new Error('deCONZ host and apiKey are required when snapshot data is not provided.'); + } + + const response = await globalThis.fetch(`${this.baseUrl()}${this.apiPath(pathArg)}`, { + method: methodArg, + headers: dataArg ? { 'content-type': 'application/json' } : undefined, + body: dataArg ? JSON.stringify(dataArg) : undefined, + }); + const text = await response.text(); + const parsed = text ? JSON.parse(text) as unknown : undefined; + + if (!response.ok) { + throw new Error(`deCONZ request ${methodArg} ${pathArg || '/'} failed with HTTP ${response.status}: ${text}`); + } + + const apiError = this.findApiError(parsed); + if (apiError) { + throw new Error(`deCONZ request ${methodArg} ${pathArg || '/'} failed: ${apiError}`); + } + + return parsed as TResult; + } + + private baseUrl(): string { + const protocol = this.config.protocol || 'http'; + const port = this.config.port ?? (protocol === 'https' ? 443 : 80); + const defaultPort = protocol === 'https' ? 443 : 80; + return `${protocol}://${this.config.host}${port === defaultPort ? '' : `:${port}`}`; + } + + private apiPath(pathArg: string): string { + const suffix = pathArg.startsWith('/') ? pathArg : pathArg ? `/${pathArg}` : ''; + return `/api/${encodeURIComponent(String(this.config.apiKey))}${suffix}`; + } + + private canUseHttp(): boolean { + return Boolean(this.config.host && this.config.apiKey); + } + + private async resolveWebSocketPort(): Promise { + if (this.config.websocketPort) { + return this.config.websocketPort; + } + if (this.config.snapshot?.config?.websocketport) { + return this.config.snapshot.config.websocketport; + } + if (!this.canUseHttp()) { + return undefined; + } + return (await this.getGatewayConfig()).websocketport; + } + + private parseWebSocketMessage(dataArg: unknown): IDeconzWebsocketEvent | undefined { + if (typeof dataArg === 'string') { + return JSON.parse(dataArg) as IDeconzWebsocketEvent; + } + if (this.isRecord(dataArg)) { + return dataArg as IDeconzWebsocketEvent; + } + return undefined; + } + + private applyLightState(lightIdArg: string, stateArg: IDeconzLightStatePatch): void { + const light = this.config.snapshot?.lights[lightIdArg]; + if (light) { + light.state = { ...light.state, ...stateArg }; + } + } + + private applyGroupState(groupIdArg: string, actionArg: IDeconzGroupActionPatch): void { + const group = this.config.snapshot?.groups[groupIdArg]; + if (!group) { + return; + } + group.action = { ...group.action, ...actionArg }; + if (typeof actionArg.on === 'boolean') { + group.state = { + ...group.state, + any_on: actionArg.on, + all_on: actionArg.on, + }; + } + } + + private applySensorConfig(sensorIdArg: string, configArg: IDeconzSensorConfigPatch): void { + const sensor = this.config.snapshot?.sensors[sensorIdArg]; + if (sensor) { + sensor.config = { ...sensor.config, ...configArg }; + } + } + + private applySensorState(sensorIdArg: string, stateArg: IDeconzSensorStatePatch): void { + const sensor = this.config.snapshot?.sensors[sensorIdArg]; + if (sensor) { + sensor.state = { ...sensor.state, ...stateArg }; + } + } + + private findApiError(valueArg: unknown): string | undefined { + if (!Array.isArray(valueArg)) { + return undefined; + } + for (const item of valueArg) { + if (!this.isRecord(item) || !this.isRecord(item.error)) { + continue; + } + const description = item.error.description; + return typeof description === 'string' ? description : JSON.stringify(item.error); + } + return undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/deconz/deconz.classes.configflow.ts b/ts/integrations/deconz/deconz.classes.configflow.ts new file mode 100644 index 0000000..55dce3e --- /dev/null +++ b/ts/integrations/deconz/deconz.classes.configflow.ts @@ -0,0 +1,39 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IDeconzConfig } from './deconz.types.js'; + +export class DeconzConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect deCONZ Gateway', + description: 'Configure the local deCONZ REST API endpoint. Leave API key empty only if setup will use a snapshot fixture.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'apiKey', label: 'API key', type: 'password' }, + ], + submit: async (valuesArg) => { + const host = String(valuesArg.host || candidateArg.host || ''); + if (!host) { + return { + kind: 'error', + title: 'deCONZ configuration incomplete', + error: 'Host is required.', + }; + } + return { + kind: 'done', + title: 'deCONZ configured', + config: { + bridgeId: candidateArg.id, + host, + port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || 80, + apiKey: valuesArg.apiKey ? String(valuesArg.apiKey) : undefined, + protocol: 'http', + }, + }; + }, + }; + } +} diff --git a/ts/integrations/deconz/deconz.classes.integration.ts b/ts/integrations/deconz/deconz.classes.integration.ts index 753ef7e..c60d61d 100644 --- a/ts/integrations/deconz/deconz.classes.integration.ts +++ b/ts/integrations/deconz/deconz.classes.integration.ts @@ -1,26 +1,275 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { DeconzClient } from './deconz.classes.client.js'; +import { DeconzConfigFlow } from './deconz.classes.configflow.js'; +import { createDeconzDiscoveryDescriptor } from './deconz.discovery.js'; +import { DeconzMapper } from './deconz.mapper.js'; +import type { IDeconzConfig, IDeconzGroupActionPatch, IDeconzLightStatePatch, IDeconzSensorConfigPatch, IDeconzSensorStatePatch } from './deconz.types.js'; -export class HomeAssistantDeconzIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "deconz", - displayName: "deCONZ", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/deconz", - "upstreamDomain": "deconz", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "pydeconz==120" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@Kane610" - ] -}, - }); +interface IDeconzServiceTarget { + entity: IIntegrationEntity; + resource: string; + resourceId: string; + stateKey?: string; + configKey?: string; +} + +export class DeconzIntegration extends BaseIntegration { + public readonly domain = 'deconz'; + public readonly displayName = 'deCONZ'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createDeconzDiscoveryDescriptor(); + public readonly configFlow = new DeconzConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/deconz', + upstreamDomain: 'deconz', + integrationType: 'hub', + iotClass: 'local_push', + requirements: ['pydeconz==120'], + codeowners: ['@Kane610'], + documentation: 'https://www.home-assistant.io/integrations/deconz', + restDocumentation: 'https://dresden-elektronik.github.io/deconz-rest-doc/', + }; + + public async setup(configArg: IDeconzConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new DeconzRuntime(new DeconzClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantDeconzIntegration extends DeconzIntegration {} + +class DeconzRuntime implements IIntegrationRuntime { + public domain = 'deconz'; + + constructor(private readonly client: DeconzClient) {} + + public async devices(): Promise { + return DeconzMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return DeconzMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: Parameters>[0]): Promise<() => Promise> { + return this.client.subscribeToEvents((eventArg) => handlerArg(DeconzMapper.toIntegrationEvent(eventArg))); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const target = await this.resolveTarget(requestArg); + if (!target) { + return { success: false, error: 'deCONZ service calls require a known target entityId or deviceId.' }; + } + + if (requestArg.domain === 'light') { + return this.callLightService(target, requestArg); + } + if (requestArg.domain === 'switch') { + return this.callSwitchService(target, requestArg); + } + if (requestArg.domain === 'cover') { + return this.callCoverService(target, requestArg); + } + if (requestArg.domain === 'sensor' || requestArg.domain === 'binary_sensor' || requestArg.domain === 'climate') { + return this.callSensorService(target, requestArg); + } + + return { success: false, error: `Unsupported deCONZ service domain: ${requestArg.domain}` }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callLightService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise { + if (targetArg.resource !== 'lights' && targetArg.resource !== 'groups') { + return { success: false, error: 'deCONZ light services require a light or group target.' }; + } + + const payload = this.lightPayload(requestArg); + if (!payload) { + return { success: false, error: `Unsupported deCONZ light service: ${requestArg.service}` }; + } + + if (targetArg.resource === 'groups') { + await this.client.setGroupState(targetArg.resourceId, payload); + } else { + await this.client.setLightState(targetArg.resourceId, payload); + } + return { success: true }; + } + + private async callSwitchService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise { + if (targetArg.resource !== 'lights') { + return { success: false, error: 'deCONZ switch services require a light-backed switch target.' }; + } + if (requestArg.service === 'turn_on') { + await this.client.setLightState(targetArg.resourceId, { on: true }); + return { success: true }; + } + if (requestArg.service === 'turn_off') { + await this.client.setLightState(targetArg.resourceId, { on: false }); + return { success: true }; + } + return { success: false, error: `Unsupported deCONZ switch service: ${requestArg.service}` }; + } + + private async callCoverService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise { + if (targetArg.resource !== 'lights') { + return { success: false, error: 'deCONZ cover services require a light-backed cover target.' }; + } + + if (requestArg.service === 'open_cover') { + await this.client.setLightState(targetArg.resourceId, { open: true }); + return { success: true }; + } + if (requestArg.service === 'close_cover') { + await this.client.setLightState(targetArg.resourceId, { open: false }); + return { success: true }; + } + if (requestArg.service === 'stop_cover') { + await this.client.setLightState(targetArg.resourceId, { lift: 'stop' }); + return { success: true }; + } + if (requestArg.service === 'set_position' || requestArg.service === 'set_percentage') { + const position = this.numberFromData(requestArg.data, requestArg.service === 'set_position' ? ['position'] : ['percentage']); + if (position === undefined) { + return { success: false, error: `deCONZ ${requestArg.service} requires a numeric position or percentage.` }; + } + await this.client.setLightState(targetArg.resourceId, { lift: this.clamp(100 - position, 0, 100) }); + return { success: true }; + } + + return { success: false, error: `Unsupported deCONZ cover service: ${requestArg.service}` }; + } + + private async callSensorService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise { + if (targetArg.resource !== 'sensors') { + return { success: false, error: 'deCONZ sensor services require a sensor-backed target.' }; + } + if (requestArg.service !== 'set_value') { + return { success: false, error: `Unsupported deCONZ ${requestArg.domain} service: ${requestArg.service}` }; + } + + const field = this.stringFromData(requestArg.data, ['field', 'attribute']) || targetArg.configKey || targetArg.stateKey; + if (!field) { + return { success: false, error: 'deCONZ set_value requires a field, attribute, or mapped sensor key.' }; + } + + const value = this.valueFromData(requestArg.data, ['value', 'temperature', 'target_temperature']); + if (value === undefined) { + return { success: false, error: 'deCONZ set_value requires a value.' }; + } + + if (targetArg.configKey || this.isSensorConfigField(field)) { + await this.client.setSensorConfig(targetArg.resourceId, { [field]: this.normalizeSensorConfigValue(field, value) } as IDeconzSensorConfigPatch); + } else { + await this.client.setSensorState(targetArg.resourceId, { [field]: value } as IDeconzSensorStatePatch); + } + return { success: true }; + } + + private lightPayload(requestArg: IServiceCallRequest): IDeconzLightStatePatch | IDeconzGroupActionPatch | undefined { + if (requestArg.service === 'turn_off') { + return { on: false }; + } + if (requestArg.service === 'turn_on') { + const payload: IDeconzLightStatePatch = { on: true }; + const brightness = this.numberFromData(requestArg.data, ['brightness']); + const brightnessPct = this.numberFromData(requestArg.data, ['brightness_pct', 'percentage']); + const transition = this.numberFromData(requestArg.data, ['transition']); + const colorTemp = this.numberFromData(requestArg.data, ['color_temp', 'color_temp_mired']); + if (brightness !== undefined) { + payload.bri = this.clamp(Math.round(brightness), 0, 255); + } else if (brightnessPct !== undefined) { + payload.bri = this.percentToBri(brightnessPct); + } + if (transition !== undefined) { + payload.transitiontime = Math.round(transition * 10); + } + if (colorTemp !== undefined) { + payload.ct = Math.round(colorTemp); + } + if (Array.isArray(requestArg.data?.xy_color)) { + payload.xy = requestArg.data.xy_color as number[]; + } + return payload; + } + if (requestArg.service === 'set_percentage') { + const percentage = this.numberFromData(requestArg.data, ['percentage']); + if (percentage === undefined) { + return undefined; + } + return { + on: percentage > 0, + bri: this.percentToBri(percentage), + }; + } + return undefined; + } + + private async resolveTarget(requestArg: IServiceCallRequest): Promise { + const entities = DeconzMapper.toEntities(await this.client.getSnapshot()); + const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain); + if (!entity) { + return undefined; + } + + const resource = entity.attributes?.deconzResource; + const resourceId = entity.attributes?.deconzId; + if (typeof resource !== 'string' || typeof resourceId !== 'string') { + return undefined; + } + return { + entity, + resource, + resourceId, + stateKey: typeof entity.attributes?.deconzStateKey === 'string' ? entity.attributes.deconzStateKey : undefined, + configKey: typeof entity.attributes?.deconzConfigKey === 'string' ? entity.attributes.deconzConfigKey : undefined, + }; + } + + private numberFromData(dataArg: Record | undefined, keysArg: string[]): number | undefined { + const value = this.valueFromData(dataArg, keysArg); + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + private stringFromData(dataArg: Record | undefined, keysArg: string[]): string | undefined { + const value = this.valueFromData(dataArg, keysArg); + return typeof value === 'string' && value ? value : undefined; + } + + private valueFromData(dataArg: Record | undefined, keysArg: string[]): unknown { + for (const key of keysArg) { + if (dataArg && key in dataArg) { + return dataArg[key]; + } + } + return undefined; + } + + private isSensorConfigField(fieldArg: string): boolean { + return new Set(['battery', 'coolsetpoint', 'fanmode', 'heatsetpoint', 'locked', 'mode', 'offset', 'on', 'preset', 'reachable', 'setvalve']).has(fieldArg); + } + + private normalizeSensorConfigValue(fieldArg: string, valueArg: unknown): unknown { + if ((fieldArg === 'heatsetpoint' || fieldArg === 'coolsetpoint') && typeof valueArg === 'number' && Math.abs(valueArg) < 100) { + return Math.round(valueArg * 100); + } + return valueArg; + } + + private percentToBri(valueArg: number): number { + return this.clamp(Math.round(valueArg / 100 * 255), 0, 255); + } + + private clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.min(maxArg, Math.max(minArg, valueArg)); } } diff --git a/ts/integrations/deconz/deconz.discovery.ts b/ts/integrations/deconz/deconz.discovery.ts new file mode 100644 index 0000000..89a670f --- /dev/null +++ b/ts/integrations/deconz/deconz.discovery.ts @@ -0,0 +1,250 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IDeconzDiscoveryRecord, IDeconzManualEntry, IDeconzMdnsRecord, IDeconzSsdpRecord } from './deconz.types.js'; + +const DECONZ_MANUFACTURER = 'dresden elektronik'; +const DECONZ_MANUFACTURER_URL = 'http://www.dresden-elektronik.de'; + +export class DeconzMdnsMatcher implements IDiscoveryMatcher { + public id = 'deconz-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize deCONZ and Phoscon mDNS records.'; + + public async matches(recordArg: IDeconzMdnsRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const txt = recordArg.txt || {}; + const id = normalizeBridgeId(txt.bridgeid || txt.id || txt.serial || txt.serialnumber); + const metadata = [recordArg.name, recordArg.type, txt.modelid, txt.model, txt.manufacturer, txt.manufacturername].join(' ').toLowerCase(); + const matched = recordArg.type?.toLowerCase() === '_deconz._tcp.local.' || hasDeconzText(metadata); + + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'mDNS record does not contain deCONZ or Phoscon metadata.', + }; + } + + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'mDNS record contains deCONZ or Phoscon metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'deconz', + id, + host: recordArg.host, + port: recordArg.port || 80, + manufacturer: 'dresden elektronik', + model: txt.modelid || txt.model || 'deCONZ', + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + }, + }, + }; + } +} + +export class DeconzSsdpMatcher implements IDiscoveryMatcher { + public id = 'deconz-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize deCONZ SSDP records by dresden elektronik manufacturer metadata.'; + + public async matches(recordArg: IDeconzSsdpRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const upnp = recordArg.upnp || {}; + const manufacturerUrl = recordArg.manufacturerURL || upnp.manufacturerURL || upnp.manufacturerUrl || ''; + const manufacturer = recordArg.manufacturer || upnp.manufacturer || ''; + const model = recordArg.modelName || recordArg.modelNumber || upnp.modelName || upnp.modelNumber || ''; + const friendlyName = recordArg.friendlyName || upnp.friendlyName || ''; + const serial = recordArg.serialNumber || upnp.serialNumber || upnp.serial || upnp.UDN; + const id = normalizeBridgeId(serial); + const metadata = [manufacturerUrl, manufacturer, model, friendlyName].join(' ').toLowerCase(); + const matched = manufacturerUrl.toLowerCase() === DECONZ_MANUFACTURER_URL || hasDeconzText(metadata); + + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'SSDP record does not contain deCONZ manufacturer metadata.', + }; + } + + const endpoint = parseEndpoint(recordArg.ssdpLocation || recordArg.location); + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'SSDP record matches deCONZ manufacturer metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: 'deconz', + id, + host: endpoint.host, + port: endpoint.port || 80, + manufacturer: DECONZ_MANUFACTURER, + model: model || 'deCONZ', + name: friendlyName || undefined, + metadata: { + manufacturer, + manufacturerUrl, + ssdpLocation: recordArg.ssdpLocation || recordArg.location, + upnp, + }, + }, + }; + } +} + +export class DeconzManualMatcher implements IDiscoveryMatcher { + public id = 'deconz-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual deCONZ setup entries by host and deCONZ metadata.'; + + public async matches(inputArg: IDeconzManualEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const metadata = [ + inputArg.name, + inputArg.manufacturer, + inputArg.model, + inputArg.metadata ? JSON.stringify(inputArg.metadata) : '', + ].join(' ').toLowerCase(); + const matched = Boolean(inputArg.host && (hasDeconzText(metadata) || inputArg.metadata?.deconz || inputArg.metadata?.phoscon)); + + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'Manual entry does not contain deCONZ setup hints.', + }; + } + + const id = normalizeBridgeId(inputArg.id); + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'Manual entry can start deCONZ setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'deconz', + id, + host: inputArg.host, + port: inputArg.port || 80, + name: inputArg.name, + manufacturer: inputArg.manufacturer || DECONZ_MANUFACTURER, + model: inputArg.model || 'deCONZ', + metadata: inputArg.metadata, + }, + }; + } +} + +export class DeconzPhosconDiscoveryMatcher implements IDiscoveryMatcher { + public id = 'deconz-phoscon-discovery-match'; + public source = 'broker' as const; + public description = 'Recognize Phoscon discovery broker records.'; + + public async matches(recordArg: IDeconzDiscoveryRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const host = recordArg.internalipaddress; + if (!host) { + return { + matched: false, + confidence: 'low', + reason: 'Phoscon discovery record does not include an internal IP address.', + }; + } + + const id = normalizeBridgeId(recordArg.id || recordArg.macaddress); + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'Phoscon discovery record contains a local deCONZ gateway endpoint.', + normalizedDeviceId: id, + candidate: { + source: 'broker', + integrationDomain: 'deconz', + id, + host, + port: Number(recordArg.internalport || 80), + name: recordArg.name, + manufacturer: DECONZ_MANUFACTURER, + model: 'deCONZ', + macAddress: recordArg.macaddress, + metadata: { discovery: recordArg }, + }, + }; + } +} + +export class DeconzCandidateValidator implements IDiscoveryValidator { + public id = 'deconz-candidate-validator'; + public description = 'Validate deCONZ candidates before starting local setup.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + const metadata = [ + candidateArg.integrationDomain, + candidateArg.manufacturer, + candidateArg.model, + candidateArg.name, + candidateArg.metadata ? JSON.stringify(candidateArg.metadata) : '', + ].join(' ').toLowerCase(); + const matched = candidateArg.integrationDomain === 'deconz' || hasDeconzText(metadata); + + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has deCONZ metadata.' : 'Candidate is not deCONZ.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: normalizeBridgeId(candidateArg.id), + }; + } +} + +export const createDeconzDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'deconz', + displayName: 'deCONZ', + }) + .addMatcher(new DeconzMdnsMatcher()) + .addMatcher(new DeconzSsdpMatcher()) + .addMatcher(new DeconzManualMatcher()) + .addMatcher(new DeconzPhosconDiscoveryMatcher()) + .addValidator(new DeconzCandidateValidator()); +}; + +export const normalizeBridgeId = (valueArg?: string): string | undefined => { + if (!valueArg) { + return undefined; + } + const cleaned = valueArg.replace(/[^a-fA-F0-9]/g, '').toUpperCase(); + return cleaned.length >= 8 ? cleaned : valueArg; +}; + +const hasDeconzText = (valueArg: string): boolean => { + return valueArg.includes('deconz') + || valueArg.includes('phoscon') + || valueArg.includes('dresden') + || valueArg.includes('conbee') + || valueArg.includes('raspbee'); +}; + +const parseEndpoint = (urlArg?: string): { host?: string; port?: number } => { + if (!urlArg) { + return {}; + } + try { + const parsed = new URL(urlArg); + return { + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : undefined, + }; + } catch { + return {}; + } +}; diff --git a/ts/integrations/deconz/deconz.mapper.ts b/ts/integrations/deconz/deconz.mapper.ts new file mode 100644 index 0000000..ad256d8 --- /dev/null +++ b/ts/integrations/deconz/deconz.mapper.ts @@ -0,0 +1,525 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { IDeconzGroup, IDeconzLight, IDeconzSensor, IDeconzSnapshot, IDeconzWebsocketEvent } from './deconz.types.js'; + +interface IDeconzSensorEntityDescription { + key: string; + platform: 'binary_sensor' | 'sensor'; + stateKey?: string; + configKey?: string; + nameSuffix?: string; + unit?: string; + deviceClass?: string; + value: (sensorArg: IDeconzSensor) => unknown; +} + +const POWER_PLUG_TYPES = new Set([ + 'on/off light', + 'on/off output', + 'on/off plug-in unit', + 'smart plug', +]); + +const COVER_TYPES = new Set([ + 'level controllable output', + 'window covering controller', + 'window covering device', +]); + +const BINARY_SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [ + { key: 'alarm', platform: 'binary_sensor', stateKey: 'alarm', deviceClass: 'safety', value: (sensorArg) => sensorArg.state?.alarm }, + { key: 'carbon_monoxide', platform: 'binary_sensor', stateKey: 'carbonmonoxide', deviceClass: 'carbon_monoxide', value: (sensorArg) => sensorArg.state?.carbonmonoxide }, + { key: 'fire', platform: 'binary_sensor', stateKey: 'fire', deviceClass: 'smoke', value: (sensorArg) => sensorArg.state?.fire }, + { key: 'flag', platform: 'binary_sensor', stateKey: 'flag', value: (sensorArg) => sensorArg.state?.flag }, + { key: 'open', platform: 'binary_sensor', stateKey: 'open', deviceClass: 'opening', value: (sensorArg) => sensorArg.state?.open }, + { key: 'presence', platform: 'binary_sensor', stateKey: 'presence', deviceClass: 'motion', value: (sensorArg) => sensorArg.state?.presence }, + { key: 'vibration', platform: 'binary_sensor', stateKey: 'vibration', deviceClass: 'vibration', value: (sensorArg) => sensorArg.state?.vibration }, + { key: 'water', platform: 'binary_sensor', stateKey: 'water', deviceClass: 'moisture', value: (sensorArg) => sensorArg.state?.water }, + { key: 'tampered', platform: 'binary_sensor', configKey: 'tampered', nameSuffix: 'Tampered', deviceClass: 'tamper', value: (sensorArg) => sensorArg.config?.tampered }, + { key: 'low_battery', platform: 'binary_sensor', configKey: 'lowbattery', nameSuffix: 'Low Battery', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.lowbattery }, +]; + +const SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [ + { key: 'air_quality', platform: 'sensor', stateKey: 'airquality', value: (sensorArg) => sensorArg.state?.airquality }, + { key: 'air_quality_ppb', platform: 'sensor', stateKey: 'airqualityppb', nameSuffix: 'PPB', unit: 'ppb', value: (sensorArg) => sensorArg.state?.airqualityppb }, + { key: 'battery', platform: 'sensor', configKey: 'battery', nameSuffix: 'Battery', unit: '%', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.battery ?? sensorArg.state?.battery }, + { key: 'button_event', platform: 'sensor', stateKey: 'buttonevent', nameSuffix: 'Button Event', value: (sensorArg) => sensorArg.state?.buttonevent }, + { key: 'consumption', platform: 'sensor', stateKey: 'consumption', unit: 'kWh', deviceClass: 'energy', value: (sensorArg) => DeconzMapper.scaleConsumption(sensorArg.state?.consumption) }, + { key: 'current', platform: 'sensor', stateKey: 'current', unit: 'A', value: (sensorArg) => sensorArg.state?.current }, + { key: 'humidity', platform: 'sensor', stateKey: 'humidity', unit: '%', deviceClass: 'humidity', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.humidity) }, + { key: 'light_level', platform: 'sensor', stateKey: 'lightlevel', unit: 'lx', deviceClass: 'illuminance', value: (sensorArg) => DeconzMapper.lightLevel(sensorArg) }, + { key: 'power', platform: 'sensor', stateKey: 'power', unit: 'W', deviceClass: 'power', value: (sensorArg) => sensorArg.state?.power }, + { key: 'pressure', platform: 'sensor', stateKey: 'pressure', unit: 'hPa', deviceClass: 'pressure', value: (sensorArg) => sensorArg.state?.pressure }, + { key: 'status', platform: 'sensor', stateKey: 'status', value: (sensorArg) => sensorArg.state?.status }, + { key: 'temperature', platform: 'sensor', stateKey: 'temperature', unit: 'C', deviceClass: 'temperature', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.temperature) }, + { key: 'voltage', platform: 'sensor', stateKey: 'voltage', unit: 'V', deviceClass: 'voltage', value: (sensorArg) => sensorArg.state?.voltage }, +]; + +export class DeconzMapper { + public static toDevices(snapshotArg: IDeconzSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = []; + const bridgeId = this.bridgeId(snapshotArg); + + devices.push({ + id: this.gatewayDeviceId(snapshotArg), + integrationDomain: 'deconz', + name: snapshotArg.config?.name || snapshotArg.config?.devicename || 'deCONZ Gateway', + protocol: 'zigbee', + manufacturer: 'dresden elektronik', + model: snapshotArg.config?.devicename || snapshotArg.config?.modelid || 'deCONZ', + online: snapshotArg.config?.rfconnected !== false, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.config?.rfconnected === false ? 'offline' : 'online', updatedAt }, + ], + metadata: { + bridgeId, + apiVersion: snapshotArg.config?.apiversion, + softwareVersion: snapshotArg.config?.swversion, + websocketPort: snapshotArg.config?.websocketport, + }, + }); + + for (const [lightId, light] of Object.entries(snapshotArg.lights)) { + devices.push(this.lightToDevice(lightId, light, updatedAt)); + } + + for (const [groupId, group] of Object.entries(snapshotArg.groups)) { + if (!group.lights?.length) { + continue; + } + devices.push(this.groupToDevice(bridgeId, groupId, group, updatedAt)); + } + + for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) { + devices.push(this.sensorToDevice(sensorId, sensor, updatedAt)); + } + + return devices; + } + + public static toEntities(snapshotArg: IDeconzSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const bridgeId = this.bridgeId(snapshotArg); + + for (const [lightId, light] of Object.entries(snapshotArg.lights)) { + entities.push(this.lightToEntity(lightId, light, usedIds)); + } + + for (const [groupId, group] of Object.entries(snapshotArg.groups)) { + if (!group.lights?.length) { + continue; + } + entities.push(this.groupToEntity(bridgeId, groupId, group, usedIds)); + } + + for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) { + if (this.isThermostat(sensor)) { + entities.push(this.climateToEntity(sensorId, sensor, usedIds)); + } + + for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) { + if (this.isThermostat(sensor) && description.key === 'temperature') { + continue; + } + const value = description.value(sensor); + if (value === undefined || value === null) { + continue; + } + entities.push(this.sensorToEntity(sensorId, sensor, description, value, usedIds)); + } + } + + return entities; + } + + public static toIntegrationEvent(eventArg: IDeconzWebsocketEvent): IIntegrationEvent { + return { + type: eventArg.e === 'added' ? 'device_added' : eventArg.e === 'deleted' ? 'device_removed' : 'state_changed', + integrationDomain: 'deconz', + data: eventArg, + timestamp: Date.now(), + }; + } + + public static scaleHundred(valueArg: unknown): number | undefined { + if (typeof valueArg !== 'number') { + return undefined; + } + return Math.abs(valueArg) > 200 ? valueArg / 100 : valueArg; + } + + public static scaleConsumption(valueArg: unknown): number | undefined { + if (typeof valueArg !== 'number') { + return undefined; + } + return Math.abs(valueArg) >= 1000 ? valueArg / 1000 : valueArg; + } + + public static lightLevel(sensorArg: IDeconzSensor): number | undefined { + if (typeof sensorArg.state?.lux === 'number') { + return sensorArg.state.lux; + } + if (typeof sensorArg.state?.lightlevel !== 'number') { + return undefined; + } + return Math.round(Math.pow(10, (sensorArg.state.lightlevel - 1) / 10000)); + } + + private static lightToDevice(lightIdArg: string, lightArg: IDeconzLight, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: this.isLightAvailable(lightArg) ? 'online' : 'offline', updatedAt: updatedAtArg }, + ]; + + if (this.isCoverLight(lightArg)) { + features.push({ id: 'position', capability: 'cover', name: 'Position', readable: true, writable: true, unit: '%' }); + this.pushDeviceState(state, 'position', this.coverPosition(lightArg), updatedAtArg); + } else if (this.isSwitchLight(lightArg)) { + features.push({ id: 'on', capability: 'switch', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg); + } else { + features.push({ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg); + if (typeof lightArg.state?.bri === 'number') { + features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }); + this.pushDeviceState(state, 'brightness', Math.round(lightArg.state.bri / 255 * 100), updatedAtArg); + } + } + + return { + id: this.lightDeviceId(lightIdArg, lightArg), + integrationDomain: 'deconz', + name: lightArg.name || `deCONZ Light ${lightIdArg}`, + protocol: 'zigbee', + manufacturer: lightArg.manufacturername || 'Unknown', + model: lightArg.modelid || lightArg.type, + online: this.isLightAvailable(lightArg), + features, + state, + metadata: { + deconzId: lightIdArg, + uniqueId: lightArg.uniqueid, + type: lightArg.type, + softwareVersion: lightArg.swversion, + }, + }; + } + + private static groupToDevice(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: 'online', updatedAt: updatedAtArg }, + { featureId: 'on', value: groupArg.state?.any_on ?? groupArg.action?.on ?? false, updatedAt: updatedAtArg }, + ]; + if (typeof groupArg.action?.bri === 'number') { + state.push({ featureId: 'brightness', value: Math.round(groupArg.action.bri / 255 * 100), updatedAt: updatedAtArg }); + } + + return { + id: this.groupDeviceId(bridgeIdArg, groupIdArg), + integrationDomain: 'deconz', + name: groupArg.name || `deCONZ Group ${groupIdArg}`, + protocol: 'zigbee', + manufacturer: 'dresden elektronik', + model: 'deCONZ group', + online: true, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }, + { id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }, + ], + state, + metadata: { + bridgeId: bridgeIdArg, + deconzId: groupIdArg, + lights: groupArg.lights, + type: groupArg.type, + }, + }; + } + + private static sensorToDevice(sensorIdArg: string, sensorArg: IDeconzSensor, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: this.isSensorAvailable(sensorArg) ? 'online' : 'offline', updatedAt: updatedAtArg }, + ]; + + if (this.isThermostat(sensorArg)) { + features.push({ id: 'target_temperature', capability: 'climate', name: 'Target Temperature', readable: true, writable: true, unit: 'C' }); + features.push({ id: 'current_temperature', capability: 'climate', name: 'Current Temperature', readable: true, writable: false, unit: 'C' }); + this.pushDeviceState(state, 'target_temperature', this.targetTemperature(sensorArg), updatedAtArg); + this.pushDeviceState(state, 'current_temperature', this.scaleHundred(sensorArg.state?.temperature), updatedAtArg); + } + + for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) { + if (this.isThermostat(sensorArg) && description.key === 'temperature') { + continue; + } + const value = description.value(sensorArg); + if (value === undefined || value === null) { + continue; + } + features.push({ + id: description.key, + capability: 'sensor', + name: this.entityName(sensorArg, description), + readable: true, + writable: Boolean(sensorArg.type?.startsWith('CLIP') && description.stateKey), + unit: description.unit, + }); + this.pushDeviceState(state, description.key, description.platform === 'binary_sensor' ? Boolean(value) : value, updatedAtArg); + } + + return { + id: this.sensorDeviceId(sensorIdArg, sensorArg), + integrationDomain: 'deconz', + name: sensorArg.name || `deCONZ Sensor ${sensorIdArg}`, + protocol: 'zigbee', + manufacturer: sensorArg.manufacturername || 'Unknown', + model: sensorArg.modelid || sensorArg.type, + online: this.isSensorAvailable(sensorArg), + features, + state, + metadata: { + deconzId: sensorIdArg, + uniqueId: sensorArg.uniqueid, + type: sensorArg.type, + softwareVersion: sensorArg.swversion, + }, + }; + } + + private static lightToEntity(lightIdArg: string, lightArg: IDeconzLight, usedIdsArg: Map): IIntegrationEntity { + const platform = this.isCoverLight(lightArg) ? 'cover' : this.isSwitchLight(lightArg) ? 'switch' : 'light'; + const resourcePath = `/lights/${lightIdArg}/state`; + return { + id: this.entityId(platform, lightArg.name || `deCONZ Light ${lightIdArg}`, usedIdsArg), + uniqueId: `deconz_light_${this.slug(lightArg.uniqueid || lightIdArg)}`, + integrationDomain: 'deconz', + deviceId: this.lightDeviceId(lightIdArg, lightArg), + platform, + name: lightArg.name || `deCONZ Light ${lightIdArg}`, + state: platform === 'cover' ? this.coverState(lightArg) : lightArg.state?.on ? 'on' : 'off', + attributes: { + deconzResource: 'lights', + deconzId: lightIdArg, + deconzPath: resourcePath, + uniqueId: lightArg.uniqueid, + type: lightArg.type, + brightness: lightArg.state?.bri, + colorMode: lightArg.state?.colormode, + colorTemperature: lightArg.state?.ct, + position: platform === 'cover' ? this.coverPosition(lightArg) : undefined, + reachable: lightArg.state?.reachable, + }, + available: this.isLightAvailable(lightArg), + }; + } + + private static groupToEntity(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, usedIdsArg: Map): IIntegrationEntity { + return { + id: this.entityId('light', groupArg.name || `deCONZ Group ${groupIdArg}`, usedIdsArg), + uniqueId: `deconz_group_${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`, + integrationDomain: 'deconz', + deviceId: this.groupDeviceId(bridgeIdArg, groupIdArg), + platform: 'light', + name: groupArg.name || `deCONZ Group ${groupIdArg}`, + state: groupArg.state?.any_on || groupArg.action?.on ? 'on' : 'off', + attributes: { + deconzResource: 'groups', + deconzId: groupIdArg, + deconzPath: `/groups/${groupIdArg}/action`, + isDeconzGroup: true, + allOn: groupArg.state?.all_on, + anyOn: groupArg.state?.any_on, + brightness: groupArg.action?.bri, + lights: groupArg.lights, + }, + available: true, + }; + } + + private static sensorToEntity( + sensorIdArg: string, + sensorArg: IDeconzSensor, + descriptionArg: IDeconzSensorEntityDescription, + valueArg: unknown, + usedIdsArg: Map + ): IIntegrationEntity { + const name = this.entityName(sensorArg, descriptionArg); + return { + id: this.entityId(descriptionArg.platform, name, usedIdsArg), + uniqueId: `deconz_sensor_${this.slug(sensorArg.uniqueid || sensorIdArg)}_${descriptionArg.key}`, + integrationDomain: 'deconz', + deviceId: this.sensorDeviceId(sensorIdArg, sensorArg), + platform: descriptionArg.platform, + name, + state: descriptionArg.platform === 'binary_sensor' ? (valueArg ? 'on' : 'off') : valueArg, + attributes: { + deconzResource: 'sensors', + deconzId: sensorIdArg, + deconzPath: descriptionArg.configKey ? `/sensors/${sensorIdArg}/config` : `/sensors/${sensorIdArg}/state`, + deconzStateKey: descriptionArg.stateKey, + deconzConfigKey: descriptionArg.configKey, + deviceClass: descriptionArg.deviceClass, + unit: descriptionArg.unit, + type: sensorArg.type, + uniqueId: sensorArg.uniqueid, + lastUpdated: sensorArg.state?.lastupdated, + }, + available: this.isSensorAvailable(sensorArg), + }; + } + + private static climateToEntity(sensorIdArg: string, sensorArg: IDeconzSensor, usedIdsArg: Map): IIntegrationEntity { + return { + id: this.entityId('climate', sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`, usedIdsArg), + uniqueId: `deconz_climate_${this.slug(sensorArg.uniqueid || sensorIdArg)}`, + integrationDomain: 'deconz', + deviceId: this.sensorDeviceId(sensorIdArg, sensorArg), + platform: 'climate', + name: sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`, + state: this.climateMode(sensorArg), + attributes: { + deconzResource: 'sensors', + deconzId: sensorIdArg, + deconzPath: `/sensors/${sensorIdArg}/config`, + currentTemperature: this.scaleHundred(sensorArg.state?.temperature), + targetTemperature: this.targetTemperature(sensorArg), + fanMode: sensorArg.config?.fanmode, + preset: sensorArg.config?.preset, + locked: sensorArg.config?.locked, + valve: sensorArg.config?.valve, + mode: sensorArg.config?.mode, + }, + available: this.isSensorAvailable(sensorArg), + }; + } + + private static pushDeviceState( + stateArg: plugins.shxInterfaces.data.IDeviceState[], + featureIdArg: string, + valueArg: unknown, + updatedAtArg: string + ): void { + if (valueArg === undefined) { + return; + } + stateArg.push({ featureId: featureIdArg, value: valueArg as plugins.shxInterfaces.data.TDeviceStateValue, updatedAt: updatedAtArg }); + } + + private static entityName(sensorArg: IDeconzSensor, descriptionArg: IDeconzSensorEntityDescription): string { + const baseName = sensorArg.name || 'deCONZ Sensor'; + return descriptionArg.nameSuffix ? `${baseName} ${descriptionArg.nameSuffix}` : baseName; + } + + private static isLightAvailable(lightArg: IDeconzLight): boolean { + return lightArg.state?.reachable !== false; + } + + private static isSensorAvailable(sensorArg: IDeconzSensor): boolean { + return sensorArg.config?.reachable !== false && sensorArg.config?.on !== false; + } + + private static isSwitchLight(lightArg: IDeconzLight): boolean { + const type = lightArg.type?.toLowerCase() || ''; + return POWER_PLUG_TYPES.has(type) || type.includes('plug') || type.includes('outlet'); + } + + private static isCoverLight(lightArg: IDeconzLight): boolean { + const type = lightArg.type?.toLowerCase() || ''; + return COVER_TYPES.has(type) || lightArg.state?.lift !== undefined || lightArg.state?.tilt !== undefined; + } + + private static isThermostat(sensorArg: IDeconzSensor): boolean { + const type = sensorArg.type?.toLowerCase() || ''; + return type.includes('thermostat') + || sensorArg.config?.heatsetpoint !== undefined + || sensorArg.config?.coolsetpoint !== undefined + || sensorArg.config?.mode !== undefined && sensorArg.state?.temperature !== undefined; + } + + private static coverState(lightArg: IDeconzLight): string { + if (lightArg.state?.open === false || lightArg.state?.lift === 100) { + return 'closed'; + } + return 'open'; + } + + private static coverPosition(lightArg: IDeconzLight): number | undefined { + if (typeof lightArg.state?.lift === 'number') { + return this.clamp(100 - lightArg.state.lift, 0, 100); + } + if (typeof lightArg.state?.open === 'boolean') { + return lightArg.state.open ? 100 : 0; + } + return undefined; + } + + private static climateMode(sensorArg: IDeconzSensor): string { + if (sensorArg.config?.mode) { + return sensorArg.config.mode; + } + return sensorArg.config?.on === false ? 'off' : 'heat'; + } + + private static targetTemperature(sensorArg: IDeconzSensor): number | undefined { + if (sensorArg.config?.mode === 'cool' && typeof sensorArg.config.coolsetpoint === 'number') { + return this.scaleHundred(sensorArg.config.coolsetpoint); + } + if (typeof sensorArg.config?.heatsetpoint === 'number') { + return this.scaleHundred(sensorArg.config.heatsetpoint); + } + if (typeof sensorArg.config?.coolsetpoint === 'number') { + return this.scaleHundred(sensorArg.config.coolsetpoint); + } + return undefined; + } + + private static gatewayDeviceId(snapshotArg: IDeconzSnapshot): string { + return `deconz.gateway.${this.slug(this.bridgeId(snapshotArg))}`; + } + + private static lightDeviceId(lightIdArg: string, lightArg: IDeconzLight): string { + return `deconz.light.${this.slug(this.serialFromUniqueId(lightArg.uniqueid) || lightArg.uniqueid || lightIdArg)}`; + } + + private static groupDeviceId(bridgeIdArg: string, groupIdArg: string): string { + return `deconz.group.${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`; + } + + private static sensorDeviceId(sensorIdArg: string, sensorArg: IDeconzSensor): string { + return `deconz.sensor.${this.slug(this.serialFromUniqueId(sensorArg.uniqueid) || sensorArg.uniqueid || sensorIdArg)}`; + } + + private static bridgeId(snapshotArg: IDeconzSnapshot): string { + return snapshotArg.config?.bridgeid || snapshotArg.config?.uuid || 'unknown'; + } + + private static serialFromUniqueId(uniqueIdArg?: string): string | undefined { + return uniqueIdArg?.split('-')[0]; + } + + private static entityId(platformArg: string, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg)}`; + const count = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, count + 1); + return count === 0 ? base : `${base}_${count + 1}`; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'deconz'; + } + + private static clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.min(maxArg, Math.max(minArg, valueArg)); + } +} diff --git a/ts/integrations/deconz/deconz.types.ts b/ts/integrations/deconz/deconz.types.ts index e5e6da4..c8d3a9b 100644 --- a/ts/integrations/deconz/deconz.types.ts +++ b/ts/integrations/deconz/deconz.types.ts @@ -1,4 +1,271 @@ -export interface IHomeAssistantDeconzConfig { - // TODO: replace with the TypeScript-native config for deconz. +export type TDeconzProtocol = 'http' | 'https'; + +export type TDeconzResource = 'config' | 'groups' | 'lights' | 'scenes' | 'sensors'; + +export type TDeconzWebsocketEventName = 'added' | 'changed' | 'deleted' | 'scene-called'; + +export type TDeconzCommandMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export type TDeconzStateValue = string | number | boolean | null | undefined | number[] | string[] | Record; + +export interface IDeconzConfig { + host?: string; + port?: number; + apiKey?: string; + protocol?: TDeconzProtocol; + bridgeId?: string; + websocketPort?: number; + enableWebSocket?: boolean; + snapshot?: IDeconzSnapshot; +} + +export interface IDeconzGatewayConfig { + apiversion?: string; + bridgeid?: string; + devicename?: string; + fwversion?: string; + ipaddress?: string; + mac?: string; + modelid?: string; + name?: string; + swversion?: string; + uuid?: string; + websocketnotifyall?: boolean; + websocketport?: number; [key: string]: unknown; } + +export interface IDeconzLightState { + alert?: string; + bri?: number; + colormode?: string; + ct?: number; + effect?: string; + hue?: number; + lift?: number | 'stop'; + on?: boolean; + open?: boolean; + reachable?: boolean; + sat?: number; + speed?: number; + stop?: boolean; + tilt?: number; + xy?: [number, number] | number[]; + [key: string]: TDeconzStateValue; +} + +export interface IDeconzLight { + id?: string; + colorcapabilities?: number; + ctmax?: number; + ctmin?: number; + etag?: string; + hascolor?: boolean; + lastannounced?: string; + lastseen?: string; + manufacturername?: string; + modelid?: string; + name?: string; + state?: IDeconzLightState; + swversion?: string; + type?: string; + uniqueid?: string; + [key: string]: unknown; +} + +export interface IDeconzGroupState { + all_on?: boolean; + any_on?: boolean; + [key: string]: TDeconzStateValue; +} + +export interface IDeconzSceneReference { + id: string; + name?: string; +} + +export interface IDeconzGroup { + id?: string; + action?: IDeconzLightState; + devicemembership?: string[]; + etag?: string; + hidden?: boolean; + lights?: string[]; + lightsequence?: string[]; + multideviceids?: string[]; + name?: string; + scenes?: IDeconzSceneReference[] | Record; + state?: IDeconzGroupState; + type?: string; + class?: string; + [key: string]: unknown; +} + +export interface IDeconzSensorState { + airquality?: string; + airqualityppb?: number; + alarm?: boolean; + battery?: number; + buttonevent?: number | null; + carbonmonoxide?: boolean; + consumption?: number; + current?: number; + dark?: boolean; + daylight?: boolean; + fire?: boolean; + flag?: boolean; + gesture?: number; + humidity?: number; + lastupdated?: string; + lightlevel?: number; + lux?: number; + open?: boolean; + power?: number; + presence?: boolean; + pressure?: number; + status?: number; + temperature?: number; + vibration?: boolean; + voltage?: number; + water?: boolean; + [key: string]: TDeconzStateValue; +} + +export interface IDeconzSensorConfig { + battery?: number | null; + coolsetpoint?: number; + fanmode?: string; + heatsetpoint?: number; + locked?: boolean; + lowbattery?: boolean; + mode?: string; + offset?: number; + on?: boolean; + preset?: string; + reachable?: boolean; + tampered?: boolean; + temperature?: number; + valve?: number; + [key: string]: TDeconzStateValue; +} + +export interface IDeconzSensor { + id?: string; + config?: IDeconzSensorConfig; + ep?: number; + etag?: string; + lastseen?: string; + manufacturername?: string; + mode?: number; + modelid?: string; + name?: string; + state?: IDeconzSensorState; + swversion?: string; + type?: string; + uniqueid?: string; + [key: string]: unknown; +} + +export interface IDeconzSceneLightState { + id?: string; + bri?: number; + ct?: number; + hue?: number; + on?: boolean; + sat?: number; + transitiontime?: number; + x?: number; + y?: number; + xy?: [number, number] | number[]; + [key: string]: TDeconzStateValue; +} + +export interface IDeconzScene { + id?: string; + groupId?: string; + lights?: string[] | IDeconzSceneLightState[]; + name?: string; + state?: number; + [key: string]: unknown; +} + +export interface IDeconzSnapshot { + config?: IDeconzGatewayConfig; + groups: Record; + lights: Record; + sensors: Record; + scenes?: Record; + [key: string]: unknown; +} + +export interface IDeconzLightStatePatch extends Partial {} + +export interface IDeconzGroupActionPatch extends Partial {} + +export interface IDeconzSensorConfigPatch extends Partial {} + +export interface IDeconzSensorStatePatch extends Partial {} + +export interface IDeconzCommand { + method: TDeconzCommandMethod; + path: string; + data?: Record; + resource?: TDeconzResource; + resourceId?: string; +} + +export interface IDeconzWebsocketEvent { + t?: 'event'; + e: TDeconzWebsocketEventName; + r: 'groups' | 'lights' | 'scenes' | 'sensors'; + id?: string; + gid?: string; + scid?: string; + uniqueid?: string; + name?: string; + config?: IDeconzSensorConfig; + state?: IDeconzGroupState | IDeconzLightState | IDeconzSensorState; + group?: IDeconzGroup; + light?: IDeconzLight; + sensor?: IDeconzSensor; + scene?: IDeconzScene; + [key: string]: unknown; +} + +export interface IDeconzMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IDeconzSsdpRecord { + ssdpLocation?: string; + location?: string; + manufacturer?: string; + manufacturerURL?: string; + modelName?: string; + modelNumber?: string; + friendlyName?: string; + serialNumber?: string; + upnp?: Record; +} + +export interface IDeconzManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export interface IDeconzDiscoveryRecord { + id?: string; + internalipaddress?: string; + internalport?: string | number; + macaddress?: string; + name?: string; +} diff --git a/ts/integrations/deconz/index.ts b/ts/integrations/deconz/index.ts index cea9913..bc83c70 100644 --- a/ts/integrations/deconz/index.ts +++ b/ts/integrations/deconz/index.ts @@ -1,2 +1,6 @@ +export * from './deconz.classes.client.js'; +export * from './deconz.classes.configflow.js'; export * from './deconz.classes.integration.js'; +export * from './deconz.discovery.js'; +export * from './deconz.mapper.js'; export * from './deconz.types.js'; diff --git a/ts/integrations/esphome/.generated-by-smarthome-exchange b/ts/integrations/esphome/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/esphome/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/esphome/esphome.classes.client.ts b/ts/integrations/esphome/esphome.classes.client.ts new file mode 100644 index 0000000..2c51a51 --- /dev/null +++ b/ts/integrations/esphome/esphome.classes.client.ts @@ -0,0 +1,116 @@ +import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEvent, IEsphomeSnapshot } from './esphome.types.js'; +import { EsphomeMapper } from './esphome.mapper.js'; + +type TEsphomeEventHandler = (eventArg: IEsphomeEvent) => void; + +export class EsphomeClient { + private readonly events: IEsphomeEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IEsphomeConfig) {} + + public async getSnapshot(): Promise { + return EsphomeMapper.toSnapshot(this.config, undefined, this.events); + } + + public onEvent(handlerArg: TEsphomeEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IEsphomeClientCommand): Promise { + let result: IEsphomeCommandResult; + if (this.config.commandExecutor) { + result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + } else { + result = { + success: false, + error: this.unsupportedLiveControlMessage(), + data: { command: commandArg }, + }; + } + this.emit({ + type: result.success ? 'command_mapped' : 'command_failed', + command: commandArg, + data: result, + timestamp: Date.now(), + deviceId: this.stringValue(commandArg.deviceId), + entityId: commandArg.entityId, + }); + return result; + } + + public async connectLive(): Promise { + await new EsphomeNativeApiConnection(this.config).connect(); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private emit(eventArg: IEsphomeEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IEsphomeClientCommand): IEsphomeCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IEsphomeCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private unsupportedLiveControlMessage(): string { + if (this.hasEncryptionKey()) { + return 'ESPHome live native API writes require protobuf framing plus Noise encryption support, which is not implemented. The mapped command was not sent.'; + } + if (this.hasPassword()) { + return 'ESPHome live native API writes require protobuf framing plus legacy password login support, which is not implemented. The mapped command was not sent.'; + } + return 'ESPHome live native API writes require protobuf framing, which is not implemented. The mapped command was not sent.'; + } + + private hasEncryptionKey(): boolean { + return Boolean(this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk)); + } + + private hasPassword(): boolean { + return Boolean(this.config.password || this.config.manualEntries?.some((entryArg) => entryArg.password)); + } + + private stringValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string') { + return valueArg; + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return String(valueArg); + } + return undefined; + } +} + +export class EsphomeNativeApiConnection { + constructor(private readonly config: IEsphomeConfig) {} + + public async connect(): Promise { + throw new Error(this.unsupportedMessage()); + } + + public async sendCommand(commandArg: IEsphomeClientCommand): Promise { + void commandArg; + throw new Error(this.unsupportedMessage()); + } + + private unsupportedMessage(): string { + if (this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk)) { + return 'Encrypted ESPHome native API uses Noise plus protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.'; + } + return 'ESPHome native API uses protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.'; + } +} diff --git a/ts/integrations/esphome/esphome.classes.configflow.ts b/ts/integrations/esphome/esphome.classes.configflow.ts new file mode 100644 index 0000000..029b0fa --- /dev/null +++ b/ts/integrations/esphome/esphome.classes.configflow.ts @@ -0,0 +1,51 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IEsphomeConfig } from './esphome.types.js'; +import { esphomeDefaultNativeApiPort } from './esphome.types.js'; + +export class EsphomeConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect ESPHome', + description: 'Configure an ESPHome native API device. Snapshot/manual data is supported; live protobuf control is reported explicitly if unavailable.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Native API port', type: 'number' }, + { name: 'encryptionKey', label: 'Encryption key', type: 'password' }, + { name: 'password', label: 'Legacy API password', type: 'password' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'ESPHome configured', + config: { + host: this.stringValue(valuesArg.host) || candidateArg.host || '', + port: this.numberValue(valuesArg.port) || candidateArg.port || esphomeDefaultNativeApiPort, + name: candidateArg.name, + deviceName: this.stringValue(candidateArg.metadata?.deviceName), + encryptionKey: this.stringValue(valuesArg.encryptionKey) || this.stringValue(candidateArg.metadata?.encryptionKey), + noisePsk: this.stringValue(candidateArg.metadata?.noisePsk), + password: this.stringValue(valuesArg.password) || this.stringValue(candidateArg.metadata?.password), + deviceInfo: { + name: this.stringValue(candidateArg.metadata?.deviceName) || candidateArg.name, + friendlyName: candidateArg.name, + macAddress: candidateArg.macAddress, + manufacturer: candidateArg.manufacturer, + model: candidateArg.model, + host: candidateArg.host, + port: candidateArg.port || esphomeDefaultNativeApiPort, + apiEncryptionSupported: Boolean(candidateArg.metadata?.encryptionRequired), + }, + }, + }), + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } +} diff --git a/ts/integrations/esphome/esphome.classes.integration.ts b/ts/integrations/esphome/esphome.classes.integration.ts index f5197f1..2930005 100644 --- a/ts/integrations/esphome/esphome.classes.integration.ts +++ b/ts/integrations/esphome/esphome.classes.integration.ts @@ -1,42 +1,117 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { EsphomeClient } from './esphome.classes.client.js'; +import { EsphomeConfigFlow } from './esphome.classes.configflow.js'; +import { createEsphomeDiscoveryDescriptor } from './esphome.discovery.js'; +import { EsphomeMapper } from './esphome.mapper.js'; +import type { IEsphomeClientCommand, IEsphomeConfig } from './esphome.types.js'; -export class HomeAssistantEsphomeIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "esphome", - displayName: "ESPHome", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/esphome", - "upstreamDomain": "esphome", - "integrationType": "device", - "iotClass": "local_push", - "qualityScale": "platinum", - "requirements": [ - "aioesphomeapi==44.21.0", - "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.7.3" - ], - "dependencies": [ - "assist_pipeline", - "bluetooth", - "intent", - "ffmpeg", - "http" - ], - "afterDependencies": [ - "hassio", - "tag", - "usb", - "zeroconf" - ], - "codeowners": [ - "@jesserockz", - "@kbx81", - "@bdraco" - ] -}, +export class EsphomeIntegration extends BaseIntegration { + public readonly domain = 'esphome'; + public readonly displayName = 'ESPHome'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createEsphomeDiscoveryDescriptor(); + public readonly configFlow = new EsphomeConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/esphome', + upstreamDomain: 'esphome', + integrationType: 'device', + iotClass: 'local_push', + qualityScale: 'platinum', + requirements: [ + 'aioesphomeapi==44.21.0', + 'esphome-dashboard-api==1.3.0', + 'bleak-esphome==3.7.3', + ], + dependencies: ['assist_pipeline', 'bluetooth', 'intent', 'ffmpeg', 'http'], + afterDependencies: ['hassio', 'tag', 'usb', 'zeroconf'], + codeowners: ['@jesserockz', '@kbx81', '@bdraco'], + documentation: 'https://www.home-assistant.io/integrations/esphome', + zeroconf: ['_esphomelib._tcp.local.'], + mqtt: ['esphome/discover/#'], + }; + + public async setup(configArg: IEsphomeConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new EsphomeRuntime(new EsphomeClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {} + +class EsphomeRuntime implements IIntegrationRuntime { + public domain = 'esphome'; + + constructor(private readonly client: EsphomeClient) {} + + public async devices(): Promise { + return EsphomeMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return EsphomeMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.type === 'command_mapped' ? 'state_changed' : 'error', + integrationDomain: 'esphome', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = requestArg.domain === 'esphome' + ? this.esphomeCommandFromService(requestArg) + : EsphomeMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `ESPHome service ${requestArg.domain}.${requestArg.service} has no safe native command mapping.` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private esphomeCommandFromService(requestArg: IServiceCallRequest): IEsphomeClientCommand | undefined { + if (requestArg.service === 'send_command' && this.isRecord(requestArg.data?.command)) { + return requestArg.data.command as unknown as IEsphomeClientCommand; + } + if (requestArg.service === 'execute_service' || requestArg.service === 'execute_action') { + const name = requestArg.data?.name; + if (typeof name !== 'string' || !name) { + return undefined; + } + return { + type: 'execute_service', + service: name, + payload: this.isRecord(requestArg.data?.data) ? requestArg.data.data : {}, + target: requestArg.target, + }; + } + return { + type: 'execute_service', + service: requestArg.service, + payload: requestArg.data || {}, + target: requestArg.target, + }; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); } } diff --git a/ts/integrations/esphome/esphome.discovery.ts b/ts/integrations/esphome/esphome.discovery.ts new file mode 100644 index 0000000..d7b7fd5 --- /dev/null +++ b/ts/integrations/esphome/esphome.discovery.ts @@ -0,0 +1,139 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IEsphomeManualEntry, IEsphomeMdnsRecord } from './esphome.types.js'; +import { esphomeDefaultNativeApiPort } from './esphome.types.js'; + +const esphomeMdnsTypes = new Set(['_esphomelib._tcp.local', '_esphome._tcp.local']); + +export class EsphomeMdnsMatcher implements IDiscoveryMatcher { + public id = 'esphome-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize ESPHome native API mDNS advertisements.'; + + public async matches(recordArg: IEsphomeMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const txt = recordArg.txt || recordArg.properties || {}; + const mac = this.normalizeMac(this.txt(txt, 'mac')); + const deviceName = this.deviceName(recordArg, txt); + const matched = esphomeMdnsTypes.has(type) || Boolean(mac || this.txt(txt, 'api_encryption') || this.txt(txt, 'friendly_name')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not an ESPHome native API advertisement.' }; + } + return { + matched: true, + confidence: mac ? 'certain' : esphomeMdnsTypes.has(type) ? 'high' : 'medium', + reason: 'mDNS record matches ESPHome native API metadata.', + normalizedDeviceId: mac || deviceName, + candidate: { + source: 'mdns', + integrationDomain: 'esphome', + id: mac || recordArg.name || recordArg.host, + host: recordArg.host || recordArg.hostname || recordArg.addresses?.[0], + port: recordArg.port || esphomeDefaultNativeApiPort, + name: this.txt(txt, 'friendly_name') || deviceName || 'ESPHome', + manufacturer: 'ESPHome', + model: 'Native API device', + macAddress: mac, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + deviceName, + encryptionRequired: Boolean(this.txt(txt, 'api_encryption')), + }, + }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private deviceName(recordArg: IEsphomeMdnsRecord, txtArg: Record): string | undefined { + const txtName = this.txt(txtArg, 'name'); + if (txtName) { + return txtName; + } + const hostname = recordArg.hostname || recordArg.name; + return hostname?.replace(/\._?esphome(?:lib)?\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, ''); + } + + private normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + if (compact.length !== 12) { + return valueArg.toLowerCase(); + } + return compact.match(/.{1,2}/g)?.join(':'); + } +} + +export class EsphomeManualMatcher implements IDiscoveryMatcher { + public id = 'esphome-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual ESPHome native API setup entries.'; + + public async matches(inputArg: IEsphomeManualEntry): Promise { + const model = inputArg.model?.toLowerCase() || ''; + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || inputArg.metadata?.esphome || inputArg.metadata?.api_encryption || model.includes('esphome') || manufacturer.includes('esphome')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ESPHome setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start ESPHome native API setup.', + normalizedDeviceId: inputArg.id || inputArg.host, + candidate: { + source: 'manual', + integrationDomain: 'esphome', + id: inputArg.id || inputArg.host, + host: inputArg.host, + port: inputArg.port || esphomeDefaultNativeApiPort, + name: inputArg.name || inputArg.deviceName || 'ESPHome', + manufacturer: inputArg.manufacturer || 'ESPHome', + model: inputArg.model || 'Native API device', + metadata: { + ...inputArg.metadata, + deviceName: inputArg.deviceName, + password: inputArg.password, + encryptionKey: inputArg.encryptionKey, + noisePsk: inputArg.noisePsk, + }, + }, + }; + } +} + +export class EsphomeCandidateValidator implements IDiscoveryValidator { + public id = 'esphome-candidate-validator'; + public description = 'Validate ESPHome native API candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const model = candidateArg.model?.toLowerCase() || ''; + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const mdnsType = typeof candidateArg.metadata?.mdnsType === 'string' ? candidateArg.metadata.mdnsType.toLowerCase().replace(/\.$/, '') : ''; + const matched = candidateArg.integrationDomain === 'esphome' || candidateArg.port === esphomeDefaultNativeApiPort || esphomeMdnsTypes.has(mdnsType) || manufacturer.includes('esphome') || model.includes('esphome') || Boolean(candidateArg.metadata?.esphome || candidateArg.metadata?.api_encryption); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has ESPHome native API metadata.' : 'Candidate is not ESPHome.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host, + }; + } +} + +export const createEsphomeDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'esphome', displayName: 'ESPHome' }) + .addMatcher(new EsphomeMdnsMatcher()) + .addMatcher(new EsphomeManualMatcher()) + .addValidator(new EsphomeCandidateValidator()); +}; diff --git a/ts/integrations/esphome/esphome.mapper.ts b/ts/integrations/esphome/esphome.mapper.ts new file mode 100644 index 0000000..e3d142e --- /dev/null +++ b/ts/integrations/esphome/esphome.mapper.ts @@ -0,0 +1,529 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IEsphomeAreaInfo, IEsphomeClientCommand, IEsphomeConfig, IEsphomeDeviceInfo, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeManualEntry, IEsphomeSnapshot } from './esphome.types.js'; +import { esphomeDefaultNativeApiPort } from './esphome.types.js'; + +export class EsphomeMapper { + public static toSnapshot(configArg: IEsphomeConfig, connectedArg?: boolean, eventsArg: IEsphomeSnapshot['events'] = []): IEsphomeSnapshot { + const source = configArg.snapshot; + const manualEntry = configArg.manualEntries?.[0]; + const deviceInfo = this.deviceInfoFromConfig(configArg, source?.deviceInfo, manualEntry); + return { + host: configArg.host || source?.host || manualEntry?.host || deviceInfo.host, + port: configArg.port || source?.port || manualEntry?.port || deviceInfo.port || esphomeDefaultNativeApiPort, + connected: connectedArg ?? source?.connected ?? false, + deviceInfo, + apiVersion: source?.apiVersion, + entities: [...(source?.entities || []), ...(configArg.entities || [])], + states: [ + ...this.normalizeStates(source?.states), + ...this.normalizeStates(configArg.states), + ], + services: [...(source?.services || []), ...(configArg.services || [])], + events: [...(source?.events || []), ...eventsArg], + }; + } + + public static toDevices(snapshotArg: IEsphomeSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const deviceInfo = snapshotArg.deviceInfo; + const devices = new Map(); + const mainDeviceId = this.mainDeviceId(snapshotArg); + devices.set(mainDeviceId, { + id: mainDeviceId, + integrationDomain: 'esphome', + name: this.deviceName(deviceInfo, snapshotArg.host), + room: this.areaName(deviceInfo.area) || deviceInfo.suggestedArea, + protocol: 'esphome', + manufacturer: this.manufacturer(deviceInfo), + model: this.model(deviceInfo), + online: snapshotArg.connected || Boolean(snapshotArg.entities.length || snapshotArg.states.length), + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'entity_count', capability: 'sensor', name: 'Entity count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt }, + { featureId: 'entity_count', value: snapshotArg.entities.length, updatedAt }, + ], + metadata: { + macAddress: this.normalizeMac(deviceInfo.macAddress), + bluetoothMacAddress: this.normalizeMac(deviceInfo.bluetoothMacAddress), + host: snapshotArg.host, + port: snapshotArg.port, + esphomeVersion: deviceInfo.esphomeVersion, + compilationTime: deviceInfo.compilationTime, + projectName: deviceInfo.projectName, + projectVersion: deviceInfo.projectVersion, + apiEncryptionSupported: deviceInfo.apiEncryptionSupported, + usesPassword: deviceInfo.usesPassword, + webserverPort: deviceInfo.webserverPort, + }, + }); + + for (const subDevice of deviceInfo.devices || []) { + const subDeviceId = this.subDeviceIdValue(subDevice); + if (subDeviceId === undefined || subDeviceId === null || String(subDeviceId) === '0') { + continue; + } + devices.set(this.subDeviceId(snapshotArg, subDeviceId), { + id: this.subDeviceId(snapshotArg, subDeviceId), + integrationDomain: 'esphome', + name: subDevice.name || this.deviceName(deviceInfo, snapshotArg.host), + room: this.areaForSubDevice(deviceInfo, subDevice.areaId ?? subDevice.area_id), + protocol: 'esphome', + manufacturer: this.manufacturer(deviceInfo), + model: this.model(deviceInfo), + online: snapshotArg.connected || Boolean(snapshotArg.entities.length || snapshotArg.states.length), + features: [], + state: [], + metadata: { + parentDeviceId: mainDeviceId, + esphomeDeviceId: subDeviceId, + }, + }); + } + + for (const entity of snapshotArg.entities) { + const deviceId = this.deviceIdForEntity(snapshotArg, entity); + const device = devices.get(deviceId) || devices.get(mainDeviceId); + if (!device) { + continue; + } + const feature = this.featureForEntity(entity); + device.features.push(feature); + device.state.push({ featureId: feature.id, value: this.deviceStateValue(this.entityState(entity, this.stateForEntity(snapshotArg, entity))), updatedAt }); + } + + return [...devices.values()]; + } + + public static toEntities(snapshotArg: IEsphomeSnapshot): IIntegrationEntity[] { + return snapshotArg.entities.map((entityArg) => { + const state = this.stateForEntity(snapshotArg, entityArg); + const platform = this.corePlatform(entityArg.platform); + const name = this.entityName(entityArg); + return { + id: entityArg.entityId || entityArg.entity_id || `${platform}.${this.slug(`${this.deviceName(snapshotArg.deviceInfo, snapshotArg.host)} ${name}`)}`, + uniqueId: this.uniqueIdForEntity(snapshotArg, entityArg), + integrationDomain: 'esphome', + deviceId: this.deviceIdForEntity(snapshotArg, entityArg), + platform, + name, + state: this.entityState(entityArg, state), + attributes: { + key: entityArg.key, + deviceId: entityArg.deviceId ?? entityArg.device_id, + esphomePlatform: entityArg.platform, + deviceClass: entityArg.deviceClass || entityArg.device_class, + unit: entityArg.unitOfMeasurement || entityArg.unit_of_measurement, + accuracyDecimals: entityArg.accuracyDecimals ?? entityArg.accuracy_decimals, + stateClass: entityArg.stateClass ?? entityArg.state_class, + entityCategory: entityArg.entityCategory || entityArg.entity_category, + icon: entityArg.icon, + options: entityArg.options, + effects: entityArg.effects, + rawState: state, + rawInfo: entityArg.raw, + }, + available: !this.missingState(state), + }; + }); + } + + public static commandForService(snapshotArg: IEsphomeSnapshot, requestArg: IServiceCallRequest): IEsphomeClientCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target) { + return undefined; + } + const platform = this.corePlatform(target.platform); + const payload = this.payloadForService(target, requestArg); + if (!payload) { + return undefined; + } + return { + type: `${platform}.command`, + service: requestArg.service, + platform: target.platform, + key: target.key, + deviceId: target.deviceId ?? target.device_id, + entityId: target.entityId || target.entity_id, + uniqueId: this.uniqueIdForEntity(snapshotArg, target), + payload, + target: requestArg.target, + }; + } + + public static corePlatform(platformArg: string): TEntityPlatform { + const platform = platformArg.toLowerCase(); + if (platform === 'text_sensor') { + return 'sensor'; + } + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + public static uniqueIdForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): string { + const explicit = entityArg.uniqueId || entityArg.unique_id; + if (explicit) { + return explicit; + } + const deviceIdentifier = this.compactDeviceIdentifier(snapshotArg.deviceInfo) || this.slug(snapshotArg.host || 'configured'); + const objectId = entityArg.objectId || entityArg.object_id || this.slug(this.entityName(entityArg)); + const subDevice = entityArg.deviceId ?? entityArg.device_id; + return `esphome_${this.slug(`${deviceIdentifier}_${entityArg.platform}_${objectId}_${entityArg.key}_${subDevice ?? 0}`)}`; + } + + private static deviceInfoFromConfig(configArg: IEsphomeConfig, sourceArg?: IEsphomeDeviceInfo, manualEntryArg?: IEsphomeManualEntry): IEsphomeDeviceInfo { + return { + ...sourceArg, + ...configArg.deviceInfo, + name: configArg.deviceInfo?.name || sourceArg?.name || configArg.deviceName || manualEntryArg?.deviceName || manualEntryArg?.name || configArg.name || configArg.host || manualEntryArg?.host || 'ESPHome', + friendlyName: configArg.deviceInfo?.friendlyName || sourceArg?.friendlyName || configArg.name || manualEntryArg?.name, + manufacturer: configArg.deviceInfo?.manufacturer || sourceArg?.manufacturer || manualEntryArg?.manufacturer, + model: configArg.deviceInfo?.model || sourceArg?.model || manualEntryArg?.model, + host: configArg.host || sourceArg?.host || manualEntryArg?.host || configArg.deviceInfo?.host, + port: configArg.port || sourceArg?.port || manualEntryArg?.port || configArg.deviceInfo?.port || esphomeDefaultNativeApiPort, + usesPassword: configArg.deviceInfo?.usesPassword ?? sourceArg?.usesPassword ?? Boolean(configArg.password || manualEntryArg?.password), + apiEncryptionSupported: configArg.deviceInfo?.apiEncryptionSupported ?? sourceArg?.apiEncryptionSupported ?? Boolean(configArg.encryptionKey || configArg.noisePsk || manualEntryArg?.encryptionKey || manualEntryArg?.noisePsk), + }; + } + + private static normalizeStates(statesArg: IEsphomeConfig['states'] | IEsphomeSnapshot['states'] | undefined): IEsphomeEntityState[] { + if (!statesArg) { + return []; + } + if (Array.isArray(statesArg)) { + return statesArg.map((stateArg) => ({ ...stateArg })); + } + return Object.entries(statesArg).map(([key, value]) => { + if (this.isRecord(value)) { + return { key, ...value } as IEsphomeEntityState; + } + return { key, state: value }; + }); + } + + private static stateForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): IEsphomeEntityState | undefined { + const uniqueId = this.uniqueIdForEntity(snapshotArg, entityArg); + const entityId = entityArg.entityId || entityArg.entity_id; + return snapshotArg.states.find((stateArg) => { + if ((stateArg.uniqueId || stateArg.unique_id) && (stateArg.uniqueId || stateArg.unique_id) === uniqueId) { + return true; + } + if (entityId && (stateArg.entityId || stateArg.entity_id) === entityId) { + return true; + } + const stateKey = stateArg.key; + if (stateKey === undefined || String(stateKey) !== String(entityArg.key)) { + return false; + } + const statePlatform = stateArg.platform; + if (statePlatform && this.corePlatform(String(statePlatform)) !== this.corePlatform(entityArg.platform)) { + return false; + } + const entityDeviceId = entityArg.deviceId ?? entityArg.device_id; + const stateDeviceId = stateArg.deviceId ?? stateArg.device_id; + return stateDeviceId === undefined || entityDeviceId === undefined || String(stateDeviceId) === String(entityDeviceId); + }); + } + + private static entityState(entityArg: IEsphomeEntityDescriptor, stateArg?: IEsphomeEntityState): unknown { + if (this.missingState(stateArg)) { + return 'unknown'; + } + const rawValue = this.rawStateValue(stateArg); + const platform = this.corePlatform(entityArg.platform); + if (platform === 'light' || platform === 'switch' || platform === 'fan') { + if (typeof rawValue === 'boolean') { + return rawValue ? 'on' : 'off'; + } + if (this.isRecord(stateArg) && typeof stateArg.state === 'boolean') { + return stateArg.state ? 'on' : 'off'; + } + } + if (platform === 'cover') { + const position = this.numberValue(stateArg?.position); + if (position !== undefined) { + return Math.round(position * 100); + } + if (typeof rawValue === 'boolean') { + return rawValue ? 'open' : 'closed'; + } + } + if (platform === 'climate') { + return stateArg?.mode ?? stateArg?.hvacMode ?? stateArg?.hvac_mode ?? rawValue ?? 'unknown'; + } + if (platform === 'button') { + return rawValue ?? 'idle'; + } + return rawValue ?? 'unknown'; + } + + private static payloadForService(entityArg: IEsphomeEntityDescriptor, requestArg: IServiceCallRequest): Record | undefined { + const platform = this.corePlatform(entityArg.platform); + if (requestArg.service === 'turn_on') { + if (!['light', 'switch', 'fan', 'climate'].includes(platform)) { + return undefined; + } + return { + state: true, + ...this.lightPayload(requestArg), + ...this.fanPayload(requestArg), + }; + } + if (requestArg.service === 'turn_off') { + if (!['light', 'switch', 'fan', 'climate'].includes(platform)) { + return undefined; + } + return { state: false }; + } + if (requestArg.service === 'set_value') { + if (!['number', 'text'].includes(platform) || requestArg.data?.value === undefined) { + return undefined; + } + return { value: requestArg.data.value }; + } + if (requestArg.service === 'press') { + return platform === 'button' ? { press: true } : undefined; + } + if (requestArg.service === 'set_position') { + const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.positionPercentage ?? requestArg.data?.position_percentage); + return platform === 'cover' && position !== undefined ? { position: this.percentToFraction(position), positionPercentage: this.clampPercent(position) } : undefined; + } + if (requestArg.service === 'set_percentage') { + const percentage = this.numberValue(requestArg.data?.percentage); + return platform === 'fan' && percentage !== undefined ? { state: percentage > 0, percentage: this.clampPercent(percentage) } : undefined; + } + if (requestArg.service === 'select_option') { + const option = requestArg.data?.option; + return platform === 'select' && typeof option === 'string' ? { option } : undefined; + } + if (requestArg.service === 'open_cover') { + return platform === 'cover' ? { position: 1, positionPercentage: 100 } : undefined; + } + if (requestArg.service === 'close_cover') { + return platform === 'cover' ? { position: 0, positionPercentage: 0 } : undefined; + } + if (requestArg.service === 'stop_cover') { + return platform === 'cover' ? { stop: true } : undefined; + } + if (requestArg.service === 'set_temperature') { + const temperature = this.numberValue(requestArg.data?.temperature); + return platform === 'climate' && temperature !== undefined ? { targetTemperature: temperature } : undefined; + } + return undefined; + } + + private static findTargetEntity(snapshotArg: IEsphomeSnapshot, requestArg: IServiceCallRequest): IEsphomeEntityDescriptor | undefined { + if (requestArg.target.entityId) { + const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + if (!entity) { + return undefined; + } + const target = snapshotArg.entities.find((entityArg) => this.uniqueIdForEntity(snapshotArg, entityArg) === entity.uniqueId); + return target && this.serviceMatchesEntity(target, requestArg) ? target : undefined; + } + const candidates = requestArg.target.deviceId + ? snapshotArg.entities.filter((entityArg) => this.deviceIdForEntity(snapshotArg, entityArg) === requestArg.target.deviceId) + : snapshotArg.entities; + return candidates.find((entityArg) => this.serviceMatchesEntity(entityArg, requestArg)); + } + + private static serviceMatchesEntity(entityArg: IEsphomeEntityDescriptor, requestArg: IServiceCallRequest): boolean { + if (!this.entityWritable(entityArg)) { + return false; + } + const domainPlatform = this.domainPlatform(requestArg.domain); + if (domainPlatform && this.corePlatform(entityArg.platform) !== domainPlatform) { + return false; + } + return Boolean(this.payloadForService(entityArg, requestArg)); + } + + private static domainPlatform(domainArg: string): TEntityPlatform | undefined { + if (domainArg === 'esphome') { + return undefined; + } + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(domainArg as TEntityPlatform) ? domainArg as TEntityPlatform : undefined; + } + + private static entityWritable(entityArg: IEsphomeEntityDescriptor): boolean { + if (typeof entityArg.writable === 'boolean') { + return entityArg.writable; + } + return ['light', 'switch', 'fan', 'cover', 'climate', 'button', 'number', 'select', 'text'].includes(this.corePlatform(entityArg.platform)); + } + + private static featureForEntity(entityArg: IEsphomeEntityDescriptor): plugins.shxInterfaces.data.IDeviceFeature { + const platform = this.corePlatform(entityArg.platform); + return { + id: this.slug(`${entityArg.platform}_${entityArg.objectId || entityArg.object_id || entityArg.key}`), + capability: this.capabilityForPlatform(platform), + name: this.entityName(entityArg), + readable: true, + writable: this.entityWritable(entityArg), + unit: entityArg.unitOfMeasurement || entityArg.unit_of_measurement, + }; + } + + private static capabilityForPlatform(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability { + if (platformArg === 'light') { + return 'light'; + } + if (platformArg === 'cover') { + return 'cover'; + } + if (platformArg === 'climate') { + return 'climate'; + } + if (platformArg === 'fan') { + return 'fan'; + } + if (platformArg === 'switch' || platformArg === 'button' || platformArg === 'number' || platformArg === 'select' || platformArg === 'text') { + return 'switch'; + } + return 'sensor'; + } + + private static deviceIdForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): string { + const deviceId = entityArg.deviceId ?? entityArg.device_id; + if (deviceId === undefined || deviceId === null || String(deviceId) === '0') { + return this.mainDeviceId(snapshotArg); + } + return this.subDeviceId(snapshotArg, deviceId); + } + + private static mainDeviceId(snapshotArg: IEsphomeSnapshot): string { + return `esphome.device.${this.slug(this.compactDeviceIdentifier(snapshotArg.deviceInfo) || snapshotArg.deviceInfo.name || snapshotArg.host || 'configured')}`; + } + + private static subDeviceId(snapshotArg: IEsphomeSnapshot, subDeviceIdArg: string | number): string { + return `${this.mainDeviceId(snapshotArg)}.${this.slug(String(subDeviceIdArg))}`; + } + + private static subDeviceIdValue(valueArg: { deviceId?: number | string; device_id?: number | string }): number | string | undefined { + return valueArg.deviceId ?? valueArg.device_id; + } + + private static entityName(entityArg: IEsphomeEntityDescriptor): string { + return entityArg.name || entityArg.objectId || entityArg.object_id || `${entityArg.platform} ${entityArg.key}`; + } + + private static deviceName(deviceInfoArg: IEsphomeDeviceInfo, hostArg?: string): string { + return deviceInfoArg.friendlyName || deviceInfoArg.name || hostArg || 'ESPHome'; + } + + private static manufacturer(deviceInfoArg: IEsphomeDeviceInfo): string { + if (deviceInfoArg.manufacturer) { + return deviceInfoArg.manufacturer; + } + if (deviceInfoArg.projectName) { + return deviceInfoArg.projectName.split('.')[0] || 'ESPHome'; + } + return 'ESPHome'; + } + + private static model(deviceInfoArg: IEsphomeDeviceInfo): string | undefined { + if (deviceInfoArg.model) { + return deviceInfoArg.model; + } + if (deviceInfoArg.projectName) { + return deviceInfoArg.projectName.split('.').slice(1).join('.') || deviceInfoArg.projectName; + } + return 'ESPHome node'; + } + + private static areaForSubDevice(deviceInfoArg: IEsphomeDeviceInfo, areaIdArg: unknown): string | undefined { + const area = (deviceInfoArg.areas || []).find((areaArg) => String(areaArg.areaId ?? areaArg.area_id) === String(areaIdArg)); + return this.areaName(area); + } + + private static areaName(areaArg?: IEsphomeAreaInfo): string | undefined { + return areaArg?.name || undefined; + } + + private static rawStateValue(stateArg?: IEsphomeEntityState): unknown { + if (!stateArg) { + return undefined; + } + if ('state' in stateArg) { + return stateArg.state; + } + if ('value' in stateArg) { + return stateArg.value; + } + return undefined; + } + + private static missingState(stateArg?: IEsphomeEntityState): boolean { + return stateArg?.missingState === true || stateArg?.missing_state === true; + } + + private static lightPayload(requestArg: IServiceCallRequest): Record { + const brightness = this.numberValue(requestArg.data?.brightness); + const transition = this.numberValue(requestArg.data?.transition); + const effect = requestArg.data?.effect; + return { + ...(brightness !== undefined ? { brightness: this.clampPercent(brightness / 255 * 100) / 100 } : {}), + ...(transition !== undefined ? { transitionLength: transition } : {}), + ...(typeof effect === 'string' ? { effect } : {}), + }; + } + + private static fanPayload(requestArg: IServiceCallRequest): Record { + const percentage = this.numberValue(requestArg.data?.percentage); + const presetMode = requestArg.data?.presetMode ?? requestArg.data?.preset_mode; + return { + ...(percentage !== undefined ? { percentage: this.clampPercent(percentage) } : {}), + ...(typeof presetMode === 'string' ? { presetMode } : {}), + }; + } + + private static percentToFraction(valueArg: number): number { + return this.clampPercent(valueArg) / 100; + } + + private static clampPercent(valueArg: number): number { + return Math.max(0, Math.min(100, valueArg)); + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static compactDeviceIdentifier(deviceInfoArg: IEsphomeDeviceInfo): string | undefined { + const mac = this.normalizeMac(deviceInfoArg.macAddress); + return mac ? mac.replace(/:/g, '') : undefined; + } + + private static normalizeMac(valueArg: unknown): string | undefined { + if (typeof valueArg !== 'string') { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + if (compact.length !== 12) { + return valueArg.toLowerCase(); + } + return compact.match(/.{1,2}/g)?.join(':'); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'esphome'; + } +} diff --git a/ts/integrations/esphome/esphome.types.ts b/ts/integrations/esphome/esphome.types.ts index a3873f0..f8ec9ad 100644 --- a/ts/integrations/esphome/esphome.types.ts +++ b/ts/integrations/esphome/esphome.types.ts @@ -1,4 +1,250 @@ -export interface IHomeAssistantEsphomeConfig { - // TODO: replace with the TypeScript-native config for esphome. +import type { TEntityPlatform } from '../../core/types.js'; + +export const esphomeDefaultNativeApiPort = 6053; + +export type TEsphomeEntityPlatform = + | TEntityPlatform + | 'text_sensor' + | 'alarm_control_panel' + | 'camera' + | 'date' + | 'datetime' + | 'event' + | 'lock' + | 'time' + | 'valve' + | 'water_heater'; + +export interface IEsphomeConfig { + host?: string; + port?: number; + name?: string; + deviceName?: string; + password?: string; + encryptionKey?: string; + noisePsk?: string; + snapshot?: IEsphomeSnapshot; + deviceInfo?: IEsphomeDeviceInfo; + entities?: IEsphomeEntityDescriptor[]; + states?: IEsphomeEntityState[] | Record; + services?: IEsphomeUserService[]; + manualEntries?: IEsphomeManualEntry[]; + commandExecutor?: TEsphomeCommandExecutor; +} + +export interface IHomeAssistantEsphomeConfig extends IEsphomeConfig {} + +export interface IEsphomeDeviceInfo { + name?: string; + friendlyName?: string; + macAddress?: string; + bluetoothMacAddress?: string; + manufacturer?: string; + model?: string; + esphomeVersion?: string; + compilationTime?: string; + projectName?: string; + projectVersion?: string; + suggestedArea?: string; + area?: IEsphomeAreaInfo; + areas?: IEsphomeAreaInfo[]; + devices?: IEsphomeSubDeviceInfo[]; + webserverPort?: number; + usesPassword?: boolean; + apiEncryptionSupported?: boolean; + hasDeepSleep?: boolean; + host?: string; + port?: number; [key: string]: unknown; } + +export interface IEsphomeAreaInfo { + areaId?: number | string; + area_id?: number | string; + name?: string; +} + +export interface IEsphomeSubDeviceInfo { + deviceId?: number | string; + device_id?: number | string; + name?: string; + areaId?: number | string; + area_id?: number | string; + [key: string]: unknown; +} + +export interface IEsphomeEntityDescriptor { + platform: TEsphomeEntityPlatform | string; + key: number | string; + deviceId?: number | string; + device_id?: number | string; + name?: string; + objectId?: string; + object_id?: string; + uniqueId?: string; + unique_id?: string; + entityId?: string; + entity_id?: string; + disabledByDefault?: boolean; + disabled_by_default?: boolean; + deviceClass?: string; + device_class?: string; + icon?: string; + entityCategory?: string; + entity_category?: string; + unitOfMeasurement?: string; + unit_of_measurement?: string; + accuracyDecimals?: number; + accuracy_decimals?: number; + stateClass?: string | number; + state_class?: string | number; + assumedState?: boolean; + assumed_state?: boolean; + writable?: boolean; + minValue?: number; + min_value?: number; + maxValue?: number; + max_value?: number; + step?: number; + mode?: string | number; + options?: string[]; + effects?: string[]; + supportsPosition?: boolean; + supports_position?: boolean; + supportsTilt?: boolean; + supports_tilt?: boolean; + supportsStop?: boolean; + supports_stop?: boolean; + supportsSpeed?: boolean; + supports_speed?: boolean; + supportedSpeedCount?: number; + supported_speed_count?: number; + supportedModes?: Array; + supported_modes?: Array; + supportedFanModes?: Array; + supported_fan_modes?: Array; + supportedPresetModes?: string[]; + supported_preset_modes?: string[]; + raw?: Record; + [key: string]: unknown; +} + +export interface IEsphomeEntityState { + platform?: TEsphomeEntityPlatform | string; + key?: number | string; + deviceId?: number | string; + device_id?: number | string; + uniqueId?: string; + unique_id?: string; + entityId?: string; + entity_id?: string; + state?: unknown; + value?: unknown; + missingState?: boolean; + missing_state?: boolean; + updatedAt?: number | string; + updated_at?: number | string; + raw?: Record; + [key: string]: unknown; +} + +export interface IEsphomeSnapshot { + host?: string; + port: number; + connected: boolean; + deviceInfo: IEsphomeDeviceInfo; + apiVersion?: IEsphomeApiVersion; + entities: IEsphomeEntityDescriptor[]; + states: IEsphomeEntityState[]; + services?: IEsphomeUserService[]; + events: IEsphomeEvent[]; +} + +export interface IEsphomeApiVersion { + major?: number; + minor?: number; + patch?: number; +} + +export interface IEsphomeEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + command?: IEsphomeClientCommand; + data?: unknown; +} + +export interface IEsphomeUserService { + key?: number | string; + name: string; + args?: IEsphomeUserServiceArg[]; + supportsResponse?: 'none' | 'status' | 'optional' | 'only'; + supports_response?: 'none' | 'status' | 'optional' | 'only'; + [key: string]: unknown; +} + +export interface IEsphomeUserServiceArg { + name: string; + type: 'bool' | 'int' | 'float' | 'string' | 'bool_array' | 'int_array' | 'float_array' | 'string_array' | string | number; + [key: string]: unknown; +} + +export interface IEsphomeClientCommand { + type: string; + service: string; + platform?: TEsphomeEntityPlatform | string; + key?: number | string; + deviceId?: number | string; + entityId?: string; + uniqueId?: string; + payload: Record; + target?: { + entityId?: string; + deviceId?: string; + }; +} + +export interface IEsphomeCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export type TEsphomeCommandExecutor = ( + commandArg: IEsphomeClientCommand +) => Promise | IEsphomeCommandResult | unknown; + +export interface IEsphomeMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface IEsphomeManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + deviceName?: string; + model?: string; + manufacturer?: string; + password?: string; + encryptionKey?: string; + noisePsk?: string; + metadata?: Record; +} + +export interface IEsphomeDiscoveryRecord { + source?: 'mdns' | 'manual' | 'mqtt' | string; + host?: string; + port?: number; + name?: string; + macAddress?: string; + metadata?: Record; +} diff --git a/ts/integrations/esphome/index.ts b/ts/integrations/esphome/index.ts index 17de065..e23cbc3 100644 --- a/ts/integrations/esphome/index.ts +++ b/ts/integrations/esphome/index.ts @@ -1,2 +1,6 @@ +export * from './esphome.classes.client.js'; +export * from './esphome.classes.configflow.js'; export * from './esphome.classes.integration.js'; +export * from './esphome.discovery.js'; +export * from './esphome.mapper.js'; export * from './esphome.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index c9f81e6..d9872d6 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -231,7 +231,6 @@ import { HomeAssistantDatetimeIntegration } from '../datetime/index.js'; import { HomeAssistantDdwrtIntegration } from '../ddwrt/index.js'; import { HomeAssistantDeakoIntegration } from '../deako/index.js'; import { HomeAssistantDebugpyIntegration } from '../debugpy/index.js'; -import { HomeAssistantDeconzIntegration } from '../deconz/index.js'; import { HomeAssistantDecoraWifiIntegration } from '../decora_wifi/index.js'; import { HomeAssistantDecorquipIntegration } from '../decorquip/index.js'; import { HomeAssistantDefaultConfigIntegration } from '../default_config/index.js'; @@ -340,7 +339,6 @@ import { HomeAssistantEpsonIntegration } from '../epson/index.js'; import { HomeAssistantEq3btsmartIntegration } from '../eq3btsmart/index.js'; import { HomeAssistantEsceaIntegration } from '../escea/index.js'; import { HomeAssistantEseraOnewireIntegration } from '../esera_onewire/index.js'; -import { HomeAssistantEsphomeIntegration } from '../esphome/index.js'; import { HomeAssistantEssentIntegration } from '../essent/index.js'; import { HomeAssistantEtherscanIntegration } from '../etherscan/index.js'; import { HomeAssistantEufyIntegration } from '../eufy/index.js'; @@ -521,7 +519,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js'; import { HomeAssistantHomeeIntegration } from '../homee/index.js'; import { HomeAssistantHomekitIntegration } from '../homekit/index.js'; -import { HomeAssistantHomekitControllerIntegration } from '../homekit_controller/index.js'; import { HomeAssistantHomematicIntegration } from '../homematic/index.js'; import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js'; import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js'; @@ -724,7 +721,6 @@ import { HomeAssistantMartecIntegration } from '../martec/index.js'; import { HomeAssistantMaryttsIntegration } from '../marytts/index.js'; import { HomeAssistantMastodonIntegration } from '../mastodon/index.js'; import { HomeAssistantMatrixIntegration } from '../matrix/index.js'; -import { HomeAssistantMatterIntegration } from '../matter/index.js'; import { HomeAssistantMaxcubeIntegration } from '../maxcube/index.js'; import { HomeAssistantMaytagIntegration } from '../maytag/index.js'; import { HomeAssistantMazdaIntegration } from '../mazda/index.js'; @@ -806,7 +802,6 @@ import { HomeAssistantMyuplinkIntegration } from '../myuplink/index.js'; import { HomeAssistantNadIntegration } from '../nad/index.js'; import { HomeAssistantNamIntegration } from '../nam/index.js'; import { HomeAssistantNamecheapdnsIntegration } from '../namecheapdns/index.js'; -import { HomeAssistantNanoleafIntegration } from '../nanoleaf/index.js'; import { HomeAssistantNaswebIntegration } from '../nasweb/index.js'; import { HomeAssistantNationalGridUsIntegration } from '../national_grid_us/index.js'; import { HomeAssistantNeatoIntegration } from '../neato/index.js'; @@ -1284,7 +1279,6 @@ import { HomeAssistantTraccarIntegration } from '../traccar/index.js'; import { HomeAssistantTraccarServerIntegration } from '../traccar_server/index.js'; import { HomeAssistantTraceIntegration } from '../trace/index.js'; import { HomeAssistantTractiveIntegration } from '../tractive/index.js'; -import { HomeAssistantTradfriIntegration } from '../tradfri/index.js'; import { HomeAssistantTrafikverketCameraIntegration } from '../trafikverket_camera/index.js'; import { HomeAssistantTrafikverketFerryIntegration } from '../trafikverket_ferry/index.js'; import { HomeAssistantTrafikverketTrainIntegration } from '../trafikverket_train/index.js'; @@ -1399,7 +1393,6 @@ import { HomeAssistantWilightIntegration } from '../wilight/index.js'; import { HomeAssistantWindowIntegration } from '../window/index.js'; import { HomeAssistantWirelesstagIntegration } from '../wirelesstag/index.js'; import { HomeAssistantWithingsIntegration } from '../withings/index.js'; -import { HomeAssistantWizIntegration } from '../wiz/index.js'; import { HomeAssistantWledIntegration } from '../wled/index.js'; import { HomeAssistantWmsproIntegration } from '../wmspro/index.js'; import { HomeAssistantWolflinkIntegration } from '../wolflink/index.js'; @@ -1416,7 +1409,6 @@ import { HomeAssistantXeomaIntegration } from '../xeoma/index.js'; import { HomeAssistantXiaomiIntegration } from '../xiaomi/index.js'; import { HomeAssistantXiaomiAqaraIntegration } from '../xiaomi_aqara/index.js'; import { HomeAssistantXiaomiBleIntegration } from '../xiaomi_ble/index.js'; -import { HomeAssistantXiaomiMiioIntegration } from '../xiaomi_miio/index.js'; import { HomeAssistantXiaomiTvIntegration } from '../xiaomi_tv/index.js'; import { HomeAssistantXmppIntegration } from '../xmpp/index.js'; import { HomeAssistantXs1Integration } from '../xs1/index.js'; @@ -1428,7 +1420,6 @@ import { HomeAssistantYamahaMusiccastIntegration } from '../yamaha_musiccast/ind import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js'; import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js'; import { HomeAssistantYardianIntegration } from '../yardian/index.js'; -import { HomeAssistantYeelightIntegration } from '../yeelight/index.js'; import { HomeAssistantYeelightsunflowerIntegration } from '../yeelightsunflower/index.js'; import { HomeAssistantYiIntegration } from '../yi/index.js'; import { HomeAssistantYolinkIntegration } from '../yolink/index.js'; @@ -1442,7 +1433,6 @@ import { HomeAssistantZeroconfIntegration } from '../zeroconf/index.js'; import { HomeAssistantZerprocIntegration } from '../zerproc/index.js'; import { HomeAssistantZestimateIntegration } from '../zestimate/index.js'; import { HomeAssistantZeversolarIntegration } from '../zeversolar/index.js'; -import { HomeAssistantZhaIntegration } from '../zha/index.js'; import { HomeAssistantZhongHongIntegration } from '../zhong_hong/index.js'; import { HomeAssistantZiggoMediaboxXlIntegration } from '../ziggo_mediabox_xl/index.js'; import { HomeAssistantZimiIntegration } from '../zimi/index.js'; @@ -1684,7 +1674,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDatetimeIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantDdwrtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeakoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDebugpyIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeconzIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecoraWifiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecorquipIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDefaultConfigIntegration()); @@ -1793,7 +1782,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpsonIntegration()) generatedHomeAssistantPortIntegrations.push(new HomeAssistantEq3btsmartIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsceaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEseraOnewireIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsphomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEssentIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEtherscanIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufyIntegration()); @@ -1974,7 +1962,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitControllerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration()); @@ -2177,7 +2164,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMartecIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaryttsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMastodonIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatrixIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaxcubeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaytagIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMazdaIntegration()); @@ -2259,7 +2245,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMyuplinkIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantNadIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamecheapdnsIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantNanoleafIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantNaswebIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantNationalGridUsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantNeatoIntegration()); @@ -2737,7 +2722,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarIntegration( generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarServerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraceIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTractiveIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantTradfriIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketCameraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketFerryIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketTrainIntegration()); @@ -2852,7 +2836,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantWilightIntegration( generatedHomeAssistantPortIntegrations.push(new HomeAssistantWindowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantWirelesstagIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantWithingsIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantWizIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantWledIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantWmsproIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantWolflinkIntegration()); @@ -2869,7 +2852,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantXeomaIntegration()) generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiAqaraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiBleIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiMiioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiTvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantXmppIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantXs1Integration()); @@ -2881,7 +2863,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaMusiccastInte generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightsunflowerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantYiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantYolinkIntegration()); @@ -2895,7 +2876,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeroconfIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantZerprocIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZestimateIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeversolarIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhongHongIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZiggoMediaboxXlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZimiIntegration()); @@ -2906,13 +2886,23 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1451; +export const generatedHomeAssistantPortCount = 1441; export const handwrittenHomeAssistantPortDomains = [ "cast", + "deconz", + "esphome", + "homekit_controller", "hue", + "matter", "mqtt", + "nanoleaf", "roku", "shelly", "sonos", + "tradfri", + "wiz", + "xiaomi_miio", + "yeelight", + "zha", "zwave_js" ]; diff --git a/ts/integrations/homekit_controller/.generated-by-smarthome-exchange b/ts/integrations/homekit_controller/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/homekit_controller/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/homekit_controller/homekit_controller.classes.client.ts b/ts/integrations/homekit_controller/homekit_controller.classes.client.ts new file mode 100644 index 0000000..e568fb9 --- /dev/null +++ b/ts/integrations/homekit_controller/homekit_controller.classes.client.ts @@ -0,0 +1,93 @@ +import { HomekitControllerMapper } from './homekit_controller.mapper.js'; +import type { IHomekitControllerCommand, IHomekitControllerConfig, IHomekitEvent, IHomekitSnapshot } from './homekit_controller.types.js'; + +type TEventHandler = (eventArg: IHomekitEvent) => void; + +export class HomekitControllerClient { + private readonly eventHandlers = new Set(); + private readonly events: IHomekitEvent[] = []; + private snapshot?: IHomekitSnapshot; + + constructor(private readonly config: IHomekitControllerConfig) { + this.snapshot = config.snapshot; + } + + public async getSnapshot(): Promise { + this.snapshot = HomekitControllerMapper.toSnapshot({ ...this.config, snapshot: this.snapshot }, false, this.events); + return this.snapshot; + } + + public async execute(commandArg: IHomekitControllerCommand): Promise { + if (commandArg.command === 'snapshot') { + return this.getSnapshot(); + } + if (commandArg.command === 'pair_setup') { + return this.pairSetup(commandArg); + } + if (commandArg.command === 'pair_verify') { + return this.pairVerify(commandArg); + } + if (commandArg.command === 'read_characteristics') { + return this.readCharacteristics(commandArg); + } + if (commandArg.command === 'write_characteristics' || commandArg.command === 'identify') { + return this.writeCharacteristics(commandArg); + } + if (commandArg.command === 'subscribe_events') { + return this.subscribeEvents(commandArg); + } + if (commandArg.command === 'camera_snapshot') { + return this.cameraSnapshot(commandArg); + } + throw new Error(`Unsupported HomeKit Controller command: ${commandArg.command}`); + } + + public onEvent(handlerArg: TEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async readCharacteristics(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + this.throwUnsupportedSecureSession('characteristic reads'); + } + + private async pairSetup(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + throw new Error('HomeKit pair setup is not implemented in this native port. Provide existing pairing data and a cached accessory snapshot, or use snapshot mode until native HAP SRP and secure-session support is added.'); + } + + private async pairVerify(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + this.throwUnsupportedSecureSession('pair-verify sessions'); + } + + private async subscribeEvents(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + this.throwUnsupportedSecureSession('event subscriptions'); + } + + private async writeCharacteristics(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + this.throwUnsupportedSecureSession('characteristic writes'); + } + + private async cameraSnapshot(commandArg: IHomekitControllerCommand): Promise { + void commandArg; + this.throwUnsupportedSecureSession('camera snapshots'); + } + + private throwUnsupportedSecureSession(operationArg: string): never { + if (!this.config.host && !this.config.pairingData?.AccessoryIP) { + throw new Error(`HomeKit ${operationArg} require a host/port and a HAP pair-verify encrypted session. This config only contains snapshot data.`); + } + if (!this.config.pairingData) { + throw new Error(`HomeKit ${operationArg} require pairing data and a HAP pair-verify encrypted session. Pair setup is not implemented in this native port.`); + } + throw new Error(`HomeKit ${operationArg} require HAP pair-verify session encryption. This native port exposes snapshots and mappings only until a native HAP secure-session implementation is added.`); + } +} diff --git a/ts/integrations/homekit_controller/homekit_controller.classes.configflow.ts b/ts/integrations/homekit_controller/homekit_controller.classes.configflow.ts new file mode 100644 index 0000000..ddb5090 --- /dev/null +++ b/ts/integrations/homekit_controller/homekit_controller.classes.configflow.ts @@ -0,0 +1,143 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IHomekitAccessory, IHomekitControllerConfig, IHomekitPairingData, IHomekitSnapshot } from './homekit_controller.types.js'; + +const pinFormat = /^(\d{3})-?(\d{2})-?(\d{3})$/; +const insecureCodes = new Set(['00000000', '11111111', '22222222', '33333333', '44444444', '55555555', '66666666', '77777777', '88888888', '99999999', '12345678', '87654321']); + +export class HomekitControllerConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect HomeKit Device', + description: 'Configure a HomeKit Accessory Protocol device from discovery, pairing data, or a cached accessory snapshot.', + fields: [ + { name: 'host', label: 'Host', type: 'text' }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'setupCode', label: 'Setup code', type: 'password' }, + { name: 'allowInsecureSetupCode', label: 'Allow insecure setup code', type: 'boolean' }, + { name: 'pairingData', label: 'Pairing data JSON', type: 'text' }, + { name: 'snapshotJson', label: 'Accessory snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => this.finish(valuesArg, candidateArg), + }; + } + + private async finish(valuesArg: Record, candidateArg: IDiscoveryCandidate): Promise> { + const allowInsecureSetupCode = valuesArg.allowInsecureSetupCode === true || candidateArg.metadata?.allowInsecureSetupCode === true; + const setupCode = this.formattedSetupCode(this.stringValue(valuesArg.setupCode) || this.stringValue(candidateArg.metadata?.setupCode), allowInsecureSetupCode); + if (setupCode === false) { + return { kind: 'error', title: 'Invalid setup code', error: 'HomeKit setup codes must use the 123-45-678 format and cannot be a known insecure code unless explicitly allowed.' }; + } + + const pairingData = this.jsonValue(valuesArg.pairingData) || this.objectValue(candidateArg.metadata?.pairingData); + const snapshot = this.jsonValue(valuesArg.snapshotJson) || this.objectValue(candidateArg.metadata?.snapshot); + const accessories = this.objectArrayValue(candidateArg.metadata?.accessories) || snapshot?.accessories; + const host = this.stringValue(valuesArg.host) || candidateArg.host || pairingData?.AccessoryIP || snapshot?.host; + const port = this.numberValue(valuesArg.port) || candidateArg.port || pairingData?.AccessoryPort || snapshot?.port || (host ? 51826 : undefined); + + if (!setupCode && !pairingData && !snapshot && !accessories?.length) { + return { kind: 'error', title: 'HomeKit data required', error: 'Provide a setup code, pairing data JSON, or a cached accessory snapshot.' }; + } + + const id = this.normalizeId(this.stringValue(candidateArg.id) || pairingData?.AccessoryPairingID || snapshot?.id); + return { + kind: 'done', + title: 'HomeKit Device configured', + config: { + id, + name: candidateArg.name || snapshot?.name, + host, + port, + setupCode: setupCode || undefined, + allowInsecureSetupCode: allowInsecureSetupCode || undefined, + pairingData, + paired: Boolean(pairingData || snapshot?.paired || candidateArg.metadata?.paired), + transport: snapshot?.transport || (host ? 'ip' : 'snapshot'), + model: candidateArg.model, + manufacturer: candidateArg.manufacturer, + category: candidateArg.metadata?.category as string | number | undefined, + configNumber: this.numberValue(candidateArg.metadata?.configNumber) || snapshot?.configNumber, + stateNumber: this.numberValue(candidateArg.metadata?.stateNumber) || snapshot?.stateNumber, + accessories, + snapshot, + discovery: { + source: candidateArg.source === 'manual' || candidateArg.source === 'mdns' || candidateArg.source === 'bluetooth' ? candidateArg.source : undefined, + id, + name: candidateArg.name, + host, + port, + model: candidateArg.model, + manufacturer: candidateArg.manufacturer, + category: candidateArg.metadata?.category as string | number | undefined, + paired: typeof candidateArg.metadata?.paired === 'boolean' ? candidateArg.metadata.paired : undefined, + configNumber: this.numberValue(candidateArg.metadata?.configNumber), + stateNumber: this.numberValue(candidateArg.metadata?.stateNumber), + statusFlags: this.numberValue(candidateArg.metadata?.statusFlags), + setupHash: this.stringValue(candidateArg.metadata?.setupHash), + protocolVersion: this.stringValue(candidateArg.metadata?.protocolVersion), + txt: this.objectValue>(candidateArg.metadata?.txt), + }, + connected: snapshot?.connected, + }, + }; + } + + private formattedSetupCode(valueArg: string | undefined, allowInsecureArg: boolean): string | false | undefined { + if (!valueArg) { + return undefined; + } + const match = pinFormat.exec(valueArg.trim()); + if (!match) { + return false; + } + const compact = `${match[1]}${match[2]}${match[3]}`; + if (!allowInsecureArg && insecureCodes.has(compact)) { + return false; + } + return `${match[1]}-${match[2]}-${match[3]}`; + } + + private jsonValue(valueArg: unknown): TValue | undefined { + if (typeof valueArg !== 'string' || !valueArg.trim()) { + return undefined; + } + try { + const parsed = JSON.parse(valueArg) as unknown; + return this.isRecord(parsed) || Array.isArray(parsed) ? parsed as TValue : undefined; + } catch { + return undefined; + } + } + + private objectValue(valueArg: unknown): TValue | undefined { + return this.isRecord(valueArg) ? valueArg as TValue : undefined; + } + + private objectArrayValue(valueArg: unknown): TValue[] | undefined { + return Array.isArray(valueArg) ? valueArg as TValue[] : undefined; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number.parseInt(valueArg, 10); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private normalizeId(valueArg?: string): string | undefined { + return valueArg?.trim().toLowerCase(); + } +} diff --git a/ts/integrations/homekit_controller/homekit_controller.classes.integration.ts b/ts/integrations/homekit_controller/homekit_controller.classes.integration.ts index 209e974..661e808 100644 --- a/ts/integrations/homekit_controller/homekit_controller.classes.integration.ts +++ b/ts/integrations/homekit_controller/homekit_controller.classes.integration.ts @@ -1,32 +1,84 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { HomekitControllerClient } from './homekit_controller.classes.client.js'; +import { HomekitControllerConfigFlow } from './homekit_controller.classes.configflow.js'; +import { createHomekitControllerDiscoveryDescriptor } from './homekit_controller.discovery.js'; +import { HomekitControllerMapper } from './homekit_controller.mapper.js'; +import type { IHomekitControllerConfig } from './homekit_controller.types.js'; -export class HomeAssistantHomekitControllerIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "homekit_controller", - displayName: "HomeKit Device", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/homekit_controller", - "upstreamDomain": "homekit_controller", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "aiohomekit==3.2.20" - ], - "dependencies": [ - "bluetooth_adapters", - "zeroconf" - ], - "afterDependencies": [ - "thread" - ], - "codeowners": [ - "@Jc2k", - "@bdraco" - ] -}, +export class HomekitControllerIntegration extends BaseIntegration { + public readonly domain = 'homekit_controller'; + public readonly displayName = 'HomeKit Device'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createHomekitControllerDiscoveryDescriptor(); + public readonly configFlow = new HomekitControllerConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/homekit_controller', + upstreamDomain: 'homekit_controller', + integrationType: 'device', + iotClass: 'local_push', + requirements: ['aiohomekit==3.2.20'], + dependencies: ['bluetooth_adapters', 'zeroconf'], + afterDependencies: ['thread'], + codeowners: ['@Jc2k', '@bdraco'], + zeroconf: ['_hap._tcp.local.', '_hap._udp.local.'], + documentation: 'https://www.home-assistant.io/integrations/homekit_controller', + }; + + public async setup(configArg: IHomekitControllerConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new HomekitControllerRuntime(new HomekitControllerClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantHomekitControllerIntegration extends HomekitControllerIntegration {} + +class HomekitControllerRuntime implements IIntegrationRuntime { + public domain = 'homekit_controller'; + + constructor(private readonly client: HomekitControllerClient) {} + + public async devices(): Promise { + return HomekitControllerMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return HomekitControllerMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.type === 'availability_changed' ? 'availability_changed' : eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'homekit_controller', + deviceId: typeof eventArg.aid === 'number' ? `homekit_controller.accessory.${eventArg.aid}` : undefined, + data: eventArg, + timestamp: eventArg.timestamp, + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = HomekitControllerMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `HomeKit Controller service ${requestArg.domain}.${requestArg.service} has no characteristic mapping.` }; + } + try { + const data = await this.client.execute(command); + return { success: true, data }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/homekit_controller/homekit_controller.discovery.ts b/ts/integrations/homekit_controller/homekit_controller.discovery.ts new file mode 100644 index 0000000..989b7fb --- /dev/null +++ b/ts/integrations/homekit_controller/homekit_controller.discovery.ts @@ -0,0 +1,286 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IHomekitManualEntry, IHomekitMdnsRecord } from './homekit_controller.types.js'; + +const hapMdnsTypes = new Set(['_hap._tcp.local', '_hap._udp.local']); + +const categoryNames: Record = { + 1: 'Other', + 2: 'Bridge', + 3: 'Fan', + 4: 'Garage Door Opener', + 5: 'Lightbulb', + 6: 'Door Lock', + 7: 'Outlet', + 8: 'Switch', + 9: 'Thermostat', + 10: 'Sensor', + 17: 'IP Camera', + 19: 'Air Purifier', + 20: 'Heater', + 21: 'Air Conditioner', + 22: 'Humidifier', + 23: 'Dehumidifier', + 24: 'Sprinkler', + 26: 'Window Covering', + 28: 'Security System', +}; + +export class HomekitControllerMdnsMatcher implements IDiscoveryMatcher { + public id = 'homekit-controller-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize HomeKit Accessory Protocol mDNS advertisements.'; + + public async matches(recordArg: IHomekitMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const txt = this.normalizedTxt(recordArg); + const id = this.stringValue(txt.id); + const model = this.stringValue(txt.md); + const manufacturer = this.stringValue(txt.mf); + const category = this.numberValue(txt.ci); + const matched = hapMdnsTypes.has(type) || this.hasHapTxt(txt); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a HomeKit Accessory Protocol advertisement.' }; + } + + const statusFlags = this.numberValue(txt.sf); + const paired = statusFlags === undefined ? undefined : (statusFlags & 1) === 0; + const configNumber = this.numberValue(txt['c#']); + const stateNumber = this.numberValue(txt['s#']); + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const name = this.serviceName(recordArg, txt); + + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'mDNS record matches HomeKit Accessory Protocol metadata.', + normalizedDeviceId: this.normalizeId(id), + candidate: { + source: 'mdns', + integrationDomain: 'homekit_controller', + id: this.normalizeId(id), + host, + port: recordArg.port || 51826, + name, + manufacturer, + model, + metadata: { + homekit: true, + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + id, + category, + categoryName: category === undefined ? undefined : categoryNames[category] || `Category ${category}`, + paired, + statusFlags, + configNumber, + stateNumber, + setupHash: txt.sh, + protocolVersion: txt.pv, + }, + }, + }; + } + + private normalizedTxt(recordArg: IHomekitMdnsRecord): Record { + const source = recordArg.txt || recordArg.properties || {}; + const txt: Record = {}; + for (const [key, value] of Object.entries(source)) { + txt[key.toLowerCase()] = this.txtValue(value); + } + return txt; + } + + private hasHapTxt(txtArg: Record): boolean { + return Boolean( + this.stringValue(txtArg.id) + && (txtArg.sf !== undefined + || txtArg.ci !== undefined + || txtArg['c#'] !== undefined + || txtArg['s#'] !== undefined + || txtArg.sh !== undefined + || txtArg.pv !== undefined + || txtArg.md !== undefined) + ); + } + + private txtValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string') { + return valueArg; + } + if (typeof valueArg === 'number' || typeof valueArg === 'boolean') { + return String(valueArg); + } + if (Array.isArray(valueArg)) { + return this.txtValue(valueArg[0]); + } + if (valueArg instanceof Uint8Array) { + return new TextDecoder().decode(valueArg); + } + return undefined; + } + + private serviceName(recordArg: IHomekitMdnsRecord, txtArg: Record): string | undefined { + const name = recordArg.name?.replace(/\._hap\._(?:tcp|udp)\.local\.?$/i, ''); + return this.stringValue(txtArg.name) || name; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private normalizeId(valueArg?: string): string | undefined { + return valueArg?.trim().toLowerCase(); + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number.parseInt(valueArg, 10); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } +} + +export class HomekitControllerManualMatcher implements IDiscoveryMatcher { + public id = 'homekit-controller-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual HomeKit Controller setup entries.'; + + public async matches(inputArg: IHomekitManualEntry): Promise { + const pairingId = inputArg.pairingData?.AccessoryPairingID; + const snapshot = inputArg.snapshot; + const accessories = inputArg.accessories || snapshot?.accessories; + const setupCode = this.stringValue(inputArg.setupCode) || this.stringValue(inputArg.metadata?.setupCode); + const model = inputArg.model?.toLowerCase() || ''; + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const matched = Boolean( + inputArg.host + || setupCode + || inputArg.pairingData + || accessories?.length + || inputArg.metadata?.homekit + || manufacturer.includes('homekit') + || model.includes('homekit') + ); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain HomeKit setup hints.' }; + } + + return { + matched: true, + confidence: pairingId || accessories?.length || setupCode ? 'high' : 'medium', + reason: 'Manual entry can start HomeKit Controller setup.', + normalizedDeviceId: this.normalizeId(inputArg.id || pairingId || snapshot?.id), + candidate: { + source: 'manual', + integrationDomain: 'homekit_controller', + id: this.normalizeId(inputArg.id || pairingId || snapshot?.id), + host: inputArg.host || inputArg.pairingData?.AccessoryIP || snapshot?.host, + port: inputArg.port || inputArg.pairingData?.AccessoryPort || snapshot?.port || 51826, + name: inputArg.name || snapshot?.name, + manufacturer: inputArg.manufacturer, + model: inputArg.model, + metadata: { + ...inputArg.metadata, + homekit: true, + category: inputArg.category, + categoryName: typeof inputArg.category === 'number' ? categoryNames[inputArg.category] : undefined, + pairingData: inputArg.pairingData, + accessories, + snapshot, + setupCode, + setupCodeProvided: Boolean(setupCode), + }, + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private normalizeId(valueArg?: string): string | undefined { + return valueArg?.trim().toLowerCase(); + } +} + +export class HomekitControllerCandidateValidator implements IDiscoveryValidator { + public id = 'homekit-controller-candidate-validator'; + public description = 'Validate HomeKit Controller candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const model = candidateArg.model?.toLowerCase() || ''; + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const metadata = candidateArg.metadata || {}; + const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase().replace(/\.$/, '') : ''; + const id = typeof metadata.id === 'string' ? metadata.id : candidateArg.id; + const category = this.numberValue(metadata.category); + const hasHomekitMetadata = this.hasHomekitMetadata(metadata, mdnsType, id); + const matched = Boolean( + candidateArg.integrationDomain === 'homekit_controller' + || hasHomekitMetadata + || manufacturer.includes('homekit') + || model.includes('homekit') + ); + + return { + matched, + confidence: matched && (candidateArg.host || id || metadata.pairingData || metadata.snapshot) ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has HomeKit metadata.' : 'Candidate is not HomeKit.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: id?.toLowerCase(), + metadata: matched ? { categoryName: category === undefined ? undefined : categoryNames[category] } : undefined, + }; + } + + private hasHomekitMetadata(metadataArg: Record, mdnsTypeArg: string, idArg?: string): boolean { + return Boolean( + metadataArg.homekit === true + || hapMdnsTypes.has(mdnsTypeArg) + || metadataArg.pairingData + || metadataArg.snapshot + || metadataArg.accessories + || metadataArg.setupCode + || metadataArg.setupCodeProvided + || metadataArg.statusFlags !== undefined + || metadataArg.setupHash + || metadataArg.protocolVersion + || metadataArg.configNumber !== undefined + || metadataArg.stateNumber !== undefined + || (this.looksLikeHomekitId(idArg) && (metadataArg.category !== undefined || metadataArg.txt !== undefined)) + ); + } + + private looksLikeHomekitId(valueArg?: string): boolean { + return typeof valueArg === 'string' && /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i.test(valueArg.trim()); + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number.parseInt(valueArg, 10); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } +} + +export const createHomekitControllerDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'homekit_controller', displayName: 'HomeKit Device' }) + .addMatcher(new HomekitControllerMdnsMatcher()) + .addMatcher(new HomekitControllerManualMatcher()) + .addValidator(new HomekitControllerCandidateValidator()); +}; diff --git a/ts/integrations/homekit_controller/homekit_controller.mapper.ts b/ts/integrations/homekit_controller/homekit_controller.mapper.ts new file mode 100644 index 0000000..fd3552a --- /dev/null +++ b/ts/integrations/homekit_controller/homekit_controller.mapper.ts @@ -0,0 +1,1169 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IHomekitAccessory, IHomekitCharacteristic, IHomekitCharacteristicReference, IHomekitCharacteristicWrite, IHomekitControllerCommand, IHomekitControllerConfig, IHomekitService, IHomekitSnapshot } from './homekit_controller.types.js'; + +type TKnownService = + | 'accessory_information' + | 'air_purifier' + | 'air_quality_sensor' + | 'battery_service' + | 'camera_rtp_stream_management' + | 'carbon_dioxide_sensor' + | 'carbon_monoxide_sensor' + | 'contact_sensor' + | 'fan' + | 'fan_v2' + | 'garage_door_opener' + | 'heater_cooler' + | 'humidity_sensor' + | 'leak_sensor' + | 'light_sensor' + | 'lightbulb' + | 'lock_mechanism' + | 'motion_sensor' + | 'occupancy_sensor' + | 'outlet' + | 'smoke_sensor' + | 'switch' + | 'temperature_sensor' + | 'thermostat' + | 'window' + | 'window_covering' + | 'unknown'; + +type TKnownCharacteristic = + | 'active' + | 'air_quality' + | 'battery_level' + | 'brightness' + | 'carbon_dioxide_detected' + | 'carbon_dioxide_level' + | 'carbon_monoxide_detected' + | 'charging_state' + | 'color_temperature' + | 'contact_state' + | 'current_door_state' + | 'current_heater_cooler_state' + | 'current_heating_cooling_state' + | 'current_humidity' + | 'current_light_level' + | 'current_position' + | 'current_temperature' + | 'firmware_revision' + | 'hardware_revision' + | 'heating_cooling_target' + | 'heating_threshold_temperature' + | 'cooling_threshold_temperature' + | 'hold_position' + | 'hue' + | 'identify' + | 'leak_detected' + | 'lock_current_state' + | 'lock_target_state' + | 'manufacturer' + | 'model' + | 'motion_detected' + | 'name' + | 'obstruction_detected' + | 'occupancy_detected' + | 'on' + | 'outlet_in_use' + | 'position_state' + | 'rotation_direction' + | 'rotation_speed' + | 'saturation' + | 'serial_number' + | 'smoke_detected' + | 'status_low_battery' + | 'swing_mode' + | 'target_door_state' + | 'target_heater_cooler_state' + | 'target_humidity' + | 'target_position' + | 'target_temperature' + | 'unknown'; + +interface IAccessoryInfo { + name: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareRevision?: string; + hardwareRevision?: string; +} + +interface IMappedService { + accessory: IHomekitAccessory; + service: IHomekitService; + serviceType: TKnownService; + platform: TEntityPlatform; + name: string; + state: unknown; + attributes: Record; + featureId: string; + capability: plugins.shxInterfaces.data.TDeviceCapability; + writable: boolean; +} + +const serviceTypeByToken: Record = { + '0000003e': 'accessory_information', + accessory_information: 'accessory_information', + public_hap_service_accessory_information: 'accessory_information', + '000000bb': 'air_purifier', + air_purifier: 'air_purifier', + public_hap_service_airpurifier: 'air_purifier', + '0000008d': 'air_quality_sensor', + air_quality_sensor: 'air_quality_sensor', + public_hap_service_air_quality_sensor: 'air_quality_sensor', + '00000096': 'battery_service', + battery: 'battery_service', + battery_service: 'battery_service', + public_hap_service_battery_service: 'battery_service', + '00000110': 'camera_rtp_stream_management', + camera_rtp_stream_management: 'camera_rtp_stream_management', + public_hap_service_camera_rtp_stream_management: 'camera_rtp_stream_management', + '00000097': 'carbon_dioxide_sensor', + carbon_dioxide_sensor: 'carbon_dioxide_sensor', + public_hap_service_carbon_dioxide_sensor: 'carbon_dioxide_sensor', + '0000007f': 'carbon_monoxide_sensor', + carbon_monoxide_sensor: 'carbon_monoxide_sensor', + public_hap_service_carbon_monoxide_sensor: 'carbon_monoxide_sensor', + '00000080': 'contact_sensor', + contact_sensor: 'contact_sensor', + public_hap_service_contact_sensor: 'contact_sensor', + '00000040': 'fan', + fan: 'fan', + public_hap_service_fan: 'fan', + '000000b7': 'fan_v2', + fan_v2: 'fan_v2', + fanv2: 'fan_v2', + public_hap_service_fanv2: 'fan_v2', + '00000041': 'garage_door_opener', + garage_door_opener: 'garage_door_opener', + public_hap_service_garage_door_opener: 'garage_door_opener', + '000000bc': 'heater_cooler', + heater_cooler: 'heater_cooler', + public_hap_service_heater_cooler: 'heater_cooler', + '00000082': 'humidity_sensor', + humidity_sensor: 'humidity_sensor', + public_hap_service_humidity_sensor: 'humidity_sensor', + '00000083': 'leak_sensor', + leak_sensor: 'leak_sensor', + public_hap_service_leak_sensor: 'leak_sensor', + '00000084': 'light_sensor', + light_sensor: 'light_sensor', + public_hap_service_light_sensor: 'light_sensor', + '00000043': 'lightbulb', + lightbulb: 'lightbulb', + light_bulb: 'lightbulb', + public_hap_service_lightbulb: 'lightbulb', + '00000045': 'lock_mechanism', + lock_mechanism: 'lock_mechanism', + public_hap_service_lock_mechanism: 'lock_mechanism', + '00000085': 'motion_sensor', + motion_sensor: 'motion_sensor', + public_hap_service_motion_sensor: 'motion_sensor', + '00000086': 'occupancy_sensor', + occupancy_sensor: 'occupancy_sensor', + public_hap_service_occupancy_sensor: 'occupancy_sensor', + '00000047': 'outlet', + outlet: 'outlet', + public_hap_service_outlet: 'outlet', + '00000087': 'smoke_sensor', + smoke_sensor: 'smoke_sensor', + public_hap_service_smoke_sensor: 'smoke_sensor', + '00000049': 'switch', + switch: 'switch', + public_hap_service_switch: 'switch', + '0000008a': 'temperature_sensor', + temperature_sensor: 'temperature_sensor', + public_hap_service_temperature_sensor: 'temperature_sensor', + '0000004a': 'thermostat', + thermostat: 'thermostat', + public_hap_service_thermostat: 'thermostat', + '0000008b': 'window', + window: 'window', + public_hap_service_window: 'window', + '0000008c': 'window_covering', + window_covering: 'window_covering', + public_hap_service_window_covering: 'window_covering', +}; + +const characteristicTypeByToken: Record = { + '000000b0': 'active', + active: 'active', + '00000095': 'air_quality', + air_quality: 'air_quality', + '00000068': 'battery_level', + battery_level: 'battery_level', + '00000008': 'brightness', + brightness: 'brightness', + '00000092': 'carbon_dioxide_detected', + carbon_dioxide_detected: 'carbon_dioxide_detected', + '00000093': 'carbon_dioxide_level', + carbon_dioxide_level: 'carbon_dioxide_level', + '00000069': 'carbon_monoxide_detected', + carbon_monoxide_detected: 'carbon_monoxide_detected', + '0000008f': 'charging_state', + charging_state: 'charging_state', + '000000ce': 'color_temperature', + color_temperature: 'color_temperature', + '0000006a': 'contact_state', + contact_sensor_state: 'contact_state', + contact_state: 'contact_state', + '0000000e': 'current_door_state', + current_door_state: 'current_door_state', + '000000b1': 'current_heater_cooler_state', + current_heater_cooler_state: 'current_heater_cooler_state', + '0000000f': 'current_heating_cooling_state', + current_heating_cooling_state: 'current_heating_cooling_state', + '00000010': 'current_humidity', + current_relative_humidity: 'current_humidity', + '0000006b': 'current_light_level', + current_ambient_light_level: 'current_light_level', + '0000006d': 'current_position', + current_position: 'current_position', + '00000011': 'current_temperature', + current_temperature: 'current_temperature', + '00000052': 'firmware_revision', + firmware_revision: 'firmware_revision', + '00000053': 'hardware_revision', + hardware_revision: 'hardware_revision', + '00000033': 'heating_cooling_target', + target_heating_cooling_state: 'heating_cooling_target', + heating_cooling_target: 'heating_cooling_target', + '00000012': 'heating_threshold_temperature', + heating_threshold_temperature: 'heating_threshold_temperature', + '0000000d': 'cooling_threshold_temperature', + cooling_threshold_temperature: 'cooling_threshold_temperature', + '0000006f': 'hold_position', + hold_position: 'hold_position', + '00000013': 'hue', + hue: 'hue', + '00000014': 'identify', + identify: 'identify', + '00000070': 'leak_detected', + leak_detected: 'leak_detected', + '0000001d': 'lock_current_state', + lock_current_state: 'lock_current_state', + '0000001e': 'lock_target_state', + lock_target_state: 'lock_target_state', + '00000020': 'manufacturer', + manufacturer: 'manufacturer', + '00000021': 'model', + model: 'model', + '00000022': 'motion_detected', + motion_detected: 'motion_detected', + '00000023': 'name', + name: 'name', + '00000024': 'obstruction_detected', + obstruction_detected: 'obstruction_detected', + '00000071': 'occupancy_detected', + occupancy_detected: 'occupancy_detected', + '00000025': 'on', + on: 'on', + '00000026': 'outlet_in_use', + outlet_in_use: 'outlet_in_use', + '00000072': 'position_state', + position_state: 'position_state', + '00000028': 'rotation_direction', + rotation_direction: 'rotation_direction', + '00000029': 'rotation_speed', + rotation_speed: 'rotation_speed', + '0000002f': 'saturation', + saturation: 'saturation', + '00000030': 'serial_number', + serial_number: 'serial_number', + '00000076': 'smoke_detected', + smoke_detected: 'smoke_detected', + '00000079': 'status_low_battery', + status_low_battery: 'status_low_battery', + '000000b6': 'swing_mode', + swing_mode: 'swing_mode', + '00000032': 'target_door_state', + target_door_state: 'target_door_state', + '000000b2': 'target_heater_cooler_state', + target_heater_cooler_state: 'target_heater_cooler_state', + '00000034': 'target_humidity', + target_relative_humidity: 'target_humidity', + '0000007c': 'target_position', + target_position: 'target_position', + '00000035': 'target_temperature', + target_temperature: 'target_temperature', +}; + +const writableCharacteristics = new Set([ + 'active', + 'brightness', + 'color_temperature', + 'cooling_threshold_temperature', + 'heating_cooling_target', + 'heating_threshold_temperature', + 'hold_position', + 'hue', + 'identify', + 'lock_target_state', + 'on', + 'rotation_direction', + 'rotation_speed', + 'saturation', + 'swing_mode', + 'target_door_state', + 'target_heater_cooler_state', + 'target_humidity', + 'target_position', + 'target_temperature', +]); + +export class HomekitControllerMapper { + public static toSnapshot(configArg: IHomekitControllerConfig, connectedArg = false, eventsArg: IHomekitSnapshot['events'] = []): IHomekitSnapshot { + const snapshot = configArg.snapshot; + return { + id: snapshot?.id || configArg.id || configArg.pairingData?.AccessoryPairingID, + name: snapshot?.name || configArg.name, + host: snapshot?.host || configArg.host || configArg.pairingData?.AccessoryIP, + port: snapshot?.port || configArg.port || configArg.pairingData?.AccessoryPort, + paired: snapshot?.paired ?? configArg.paired ?? Boolean(configArg.pairingData), + connected: connectedArg || snapshot?.connected || configArg.connected || false, + transport: snapshot?.transport || configArg.transport || (configArg.host || configArg.pairingData?.AccessoryIP ? 'ip' : 'snapshot'), + configNumber: snapshot?.configNumber || configArg.configNumber, + stateNumber: snapshot?.stateNumber || configArg.stateNumber, + pairingData: snapshot?.pairingData || configArg.pairingData, + discovery: snapshot?.discovery || configArg.discovery, + accessories: snapshot?.accessories || configArg.accessories || [], + events: eventsArg.length ? eventsArg : snapshot?.events || [], + }; + } + + public static toDevices(snapshotArg: IHomekitSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const mapped = this.mappedServices(snapshotArg); + return snapshotArg.accessories.map((accessoryArg) => { + const info = this.accessoryInfo(accessoryArg); + const services = mapped.filter((mappedArg) => mappedArg.accessory === accessoryArg); + const features = services.map((mappedArg): plugins.shxInterfaces.data.IDeviceFeature => ({ + id: mappedArg.featureId, + capability: mappedArg.capability, + name: mappedArg.name, + readable: true, + writable: mappedArg.writable, + unit: typeof mappedArg.attributes.unit === 'string' ? mappedArg.attributes.unit : undefined, + })); + const state = services.map((mappedArg): plugins.shxInterfaces.data.IDeviceState => ({ + featureId: mappedArg.featureId, + value: this.deviceStateValue(mappedArg.state), + updatedAt, + })); + + if (!features.length) { + features.push({ id: 'accessory', capability: 'sensor', name: 'Accessory', readable: true, writable: false }); + state.push({ featureId: 'accessory', value: 'configured', updatedAt }); + } + + return { + id: this.deviceId(snapshotArg, accessoryArg), + integrationDomain: 'homekit_controller', + name: info.name, + protocol: 'homekit', + manufacturer: info.manufacturer || snapshotArg.discovery?.manufacturer, + model: info.model || snapshotArg.discovery?.model, + online: snapshotArg.connected || Boolean(snapshotArg.accessories.length), + features, + state, + metadata: { + aid: accessoryArg.aid, + pairingId: snapshotArg.id || snapshotArg.pairingData?.AccessoryPairingID, + serialNumber: info.serialNumber, + firmwareRevision: info.firmwareRevision, + hardwareRevision: info.hardwareRevision, + configNumber: snapshotArg.configNumber, + stateNumber: snapshotArg.stateNumber, + transport: snapshotArg.transport, + }, + }; + }); + } + + public static toEntities(snapshotArg: IHomekitSnapshot): IIntegrationEntity[] { + return this.mappedServices(snapshotArg).map((mappedArg) => ({ + id: `${String(mappedArg.platform)}.${this.slug(mappedArg.name)}`, + uniqueId: `homekit_controller_${this.slug(`${snapshotArg.id || snapshotArg.pairingData?.AccessoryPairingID || 'snapshot'}_${mappedArg.accessory.aid}_${mappedArg.service.iid}`)}`, + integrationDomain: 'homekit_controller', + deviceId: this.deviceId(snapshotArg, mappedArg.accessory), + platform: mappedArg.platform, + name: mappedArg.name, + state: mappedArg.state, + attributes: mappedArg.attributes, + available: snapshotArg.connected || this.serviceHasValues(mappedArg.service), + })); + } + + public static commandForService(snapshotArg: IHomekitSnapshot, requestArg: IServiceCallRequest): IHomekitControllerCommand | undefined { + if (requestArg.domain === 'homekit_controller') { + return this.commandFromHomekitService(snapshotArg, requestArg); + } + + if (requestArg.domain === 'camera' && ['snapshot', 'camera_image', 'camera_snapshot'].includes(requestArg.service)) { + const target = this.findTargetService(snapshotArg, requestArg); + return target && this.serviceType(target.service.type) === 'camera_rtp_stream_management' + ? { command: 'camera_snapshot', aid: target.accessory.aid, width: this.numberValue(requestArg.data?.width), height: this.numberValue(requestArg.data?.height) } + : undefined; + } + + const target = this.findTargetService(snapshotArg, requestArg); + if (!target) { + return undefined; + } + + const serviceType = this.serviceType(target.service.type); + const writes = this.writesForEntityService(target.accessory, target.service, serviceType, requestArg); + return writes.length ? { command: 'write_characteristics', writes } : undefined; + } + + public static serviceType(valueArg?: string): TKnownService { + return serviceTypeByToken[this.typeToken(valueArg)] || 'unknown'; + } + + public static characteristicType(valueArg?: string): TKnownCharacteristic { + return characteristicTypeByToken[this.typeToken(valueArg)] || 'unknown'; + } + + private static mappedServices(snapshotArg: IHomekitSnapshot): IMappedService[] { + const mapped: IMappedService[] = []; + for (const accessory of snapshotArg.accessories) { + for (const service of this.accessoryServices(accessory)) { + const serviceType = this.serviceType(service.type); + if (serviceType === 'accessory_information' || serviceType === 'unknown') { + continue; + } + const platform = this.platformForService(serviceType); + if (!platform) { + continue; + } + const state = this.entityState(service, serviceType); + mapped.push({ + accessory, + service, + serviceType, + platform, + name: this.entityName(accessory, service, serviceType), + state, + attributes: this.entityAttributes(accessory, service, serviceType), + featureId: `service_${service.iid}_${serviceType}`, + capability: this.capabilityForPlatform(platform), + writable: this.serviceWritable(service, serviceType), + }); + } + } + return mapped; + } + + private static commandFromHomekitService(snapshotArg: IHomekitSnapshot, requestArg: IServiceCallRequest): IHomekitControllerCommand | undefined { + if (requestArg.service === 'snapshot') { + return { command: 'snapshot' }; + } + if (requestArg.service === 'pair' || requestArg.service === 'pair_setup') { + return { command: 'pair_setup' }; + } + if (requestArg.service === 'pair_verify') { + return { command: 'pair_verify' }; + } + if (requestArg.service === 'subscribe_events') { + return { command: 'subscribe_events' }; + } + if (requestArg.service === 'read_characteristics') { + const reads = this.referencesFromData(requestArg.data); + return reads.length ? { command: 'read_characteristics', reads } : undefined; + } + if (requestArg.service === 'write_characteristics') { + const writes = this.writesFromData(requestArg.data); + return writes.length ? { command: 'write_characteristics', writes } : undefined; + } + if (requestArg.service === 'identify') { + const accessory = this.findTargetAccessory(snapshotArg, requestArg); + const info = accessory ? this.accessoryServices(accessory).find((serviceArg) => this.serviceType(serviceArg.type) === 'accessory_information') : undefined; + const char = info ? this.char(info, 'identify') : undefined; + return accessory && info && char ? { command: 'identify', writes: [{ aid: accessory.aid, iid: char.iid, type: char.type, serviceIid: info.iid, serviceType: info.type, value: true }] } : undefined; + } + if (requestArg.service === 'camera_snapshot') { + const accessory = this.findTargetAccessory(snapshotArg, requestArg); + return { command: 'camera_snapshot', aid: accessory?.aid || this.numberValue(requestArg.data?.aid), width: this.numberValue(requestArg.data?.width), height: this.numberValue(requestArg.data?.height) }; + } + return undefined; + } + + private static writesForEntityService(accessoryArg: IHomekitAccessory, serviceArg: IHomekitService, serviceTypeArg: TKnownService, requestArg: IServiceCallRequest): IHomekitCharacteristicWrite[] { + const direct = this.writesFromData(requestArg.data); + if (requestArg.service === 'set_value' && direct.length) { + return direct; + } + + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + const on = requestArg.service === 'turn_on'; + const writes: IHomekitCharacteristicWrite[] = []; + const onChar = this.primaryOnCharacteristic(serviceArg, serviceTypeArg); + if (onChar) { + writes.push(this.write(accessoryArg, serviceArg, onChar, this.characteristicType(onChar.type) === 'active' ? (on ? 1 : 0) : on)); + } + const brightness = this.brightnessValue(requestArg.data); + const brightnessChar = this.char(serviceArg, 'brightness'); + if (on && brightness !== undefined && brightnessChar) { + writes.push(this.write(accessoryArg, serviceArg, brightnessChar, brightness)); + } + const percentage = this.numberValue(requestArg.data?.percentage); + const speedChar = this.char(serviceArg, 'rotation_speed'); + if (on && percentage !== undefined && speedChar && (serviceTypeArg === 'fan' || serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier')) { + writes.push(this.write(accessoryArg, serviceArg, speedChar, this.clampPercent(percentage))); + } + return writes; + } + + if (requestArg.service === 'set_percentage') { + const percentage = this.numberValue(requestArg.data?.percentage); + const speedChar = this.char(serviceArg, 'rotation_speed'); + if (percentage === undefined || !speedChar) { + return []; + } + const writes: IHomekitCharacteristicWrite[] = [this.write(accessoryArg, serviceArg, speedChar, this.clampPercent(percentage))]; + const onChar = this.primaryOnCharacteristic(serviceArg, serviceTypeArg); + if (onChar) { + writes.push(this.write(accessoryArg, serviceArg, onChar, this.characteristicType(onChar.type) === 'active' ? (percentage > 0 ? 1 : 0) : percentage > 0)); + } + return writes; + } + + if (requestArg.service === 'set_position' || requestArg.service === 'set_cover_position') { + const position = this.numberValue(requestArg.data?.position); + const target = this.char(serviceArg, 'target_position'); + return position !== undefined && target ? [this.write(accessoryArg, serviceArg, target, this.clampPercent(position))] : []; + } + + if (requestArg.service === 'stop_cover') { + const hold = this.char(serviceArg, 'hold_position'); + return hold ? [this.write(accessoryArg, serviceArg, hold, true)] : []; + } + + if (requestArg.service === 'open_cover' || requestArg.service === 'close_cover') { + const open = requestArg.service === 'open_cover'; + if (serviceTypeArg === 'garage_door_opener') { + const target = this.char(serviceArg, 'target_door_state'); + return target ? [this.write(accessoryArg, serviceArg, target, open ? 0 : 1)] : []; + } + const target = this.char(serviceArg, 'target_position'); + return target ? [this.write(accessoryArg, serviceArg, target, open ? 100 : 0)] : []; + } + + if (requestArg.service === 'lock' || requestArg.service === 'unlock') { + const target = this.char(serviceArg, 'lock_target_state'); + return target ? [this.write(accessoryArg, serviceArg, target, requestArg.service === 'lock' ? 1 : 0)] : []; + } + + if (requestArg.service === 'set_temperature') { + const temperature = this.numberValue(requestArg.data?.temperature ?? requestArg.data?.targetTemperature); + if (temperature === undefined) { + return []; + } + const directTarget = this.char(serviceArg, 'target_temperature'); + if (directTarget) { + return [this.write(accessoryArg, serviceArg, directTarget, temperature)]; + } + const mode = this.numberValue(this.charValue(serviceArg, 'target_heater_cooler_state')); + const threshold = mode === 2 ? this.char(serviceArg, 'cooling_threshold_temperature') : this.char(serviceArg, 'heating_threshold_temperature'); + return threshold ? [this.write(accessoryArg, serviceArg, threshold, temperature)] : []; + } + + if (requestArg.service === 'set_hvac_mode') { + const mode = this.stringValue(requestArg.data?.hvacMode ?? requestArg.data?.hvac_mode ?? requestArg.data?.mode)?.toLowerCase(); + if (!mode) { + return []; + } + if (serviceTypeArg === 'thermostat') { + const values: Record = { off: 0, heat: 1, cool: 2, heat_cool: 3, auto: 3 }; + const target = this.char(serviceArg, 'heating_cooling_target'); + return target && values[mode] !== undefined ? [this.write(accessoryArg, serviceArg, target, values[mode])] : []; + } + if (serviceTypeArg === 'heater_cooler') { + const active = this.char(serviceArg, 'active'); + if (mode === 'off') { + return active ? [this.write(accessoryArg, serviceArg, active, 0)] : []; + } + const values: Record = { heat_cool: 0, auto: 0, heat: 1, cool: 2 }; + const target = this.char(serviceArg, 'target_heater_cooler_state'); + const writes: IHomekitCharacteristicWrite[] = []; + if (active) { + writes.push(this.write(accessoryArg, serviceArg, active, 1)); + } + if (target && values[mode] !== undefined) { + writes.push(this.write(accessoryArg, serviceArg, target, values[mode])); + } + return writes; + } + } + + if (requestArg.service === 'set_value') { + const value = requestArg.data?.value; + const requestedType = this.stringValue(requestArg.data?.characteristicType || requestArg.data?.type); + const char = requestedType ? this.charByType(serviceArg, requestedType) : this.primaryWritableCharacteristic(serviceArg, serviceTypeArg, value); + return value !== undefined && char ? [this.write(accessoryArg, serviceArg, char, value)] : []; + } + + return []; + } + + private static findTargetService(snapshotArg: IHomekitSnapshot, requestArg: IServiceCallRequest): { accessory: IHomekitAccessory; service: IHomekitService } | undefined { + const mapped = this.mappedServices(snapshotArg); + if (requestArg.target.entityId) { + const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + const aid = this.numberValue(entity?.attributes?.aid); + const serviceIid = this.numberValue(entity?.attributes?.serviceIid); + return aid !== undefined && serviceIid !== undefined ? this.accessoryServiceByIid(snapshotArg, aid, serviceIid) : undefined; + } + if (requestArg.target.deviceId) { + return mapped.find((mappedArg) => this.deviceId(snapshotArg, mappedArg.accessory) === requestArg.target.deviceId && String(mappedArg.platform) === requestArg.domain) + || mapped.find((mappedArg) => this.deviceId(snapshotArg, mappedArg.accessory) === requestArg.target.deviceId); + } + return mapped.find((mappedArg) => String(mappedArg.platform) === requestArg.domain) || mapped[0]; + } + + private static findTargetAccessory(snapshotArg: IHomekitSnapshot, requestArg: IServiceCallRequest): IHomekitAccessory | undefined { + const aid = this.numberValue(requestArg.data?.aid || requestArg.data?.accessoryId); + if (aid !== undefined) { + return snapshotArg.accessories.find((accessoryArg) => accessoryArg.aid === aid); + } + if (requestArg.target.deviceId) { + return snapshotArg.accessories.find((accessoryArg) => this.deviceId(snapshotArg, accessoryArg) === requestArg.target.deviceId); + } + const target = this.findTargetService(snapshotArg, requestArg); + return target?.accessory || snapshotArg.accessories[0]; + } + + private static accessoryServiceByIid(snapshotArg: IHomekitSnapshot, aidArg: number, serviceIidArg: number): { accessory: IHomekitAccessory; service: IHomekitService } | undefined { + const accessory = snapshotArg.accessories.find((accessoryArg) => accessoryArg.aid === aidArg); + const service = accessory ? this.accessoryServices(accessory).find((serviceArg) => serviceArg.iid === serviceIidArg) : undefined; + return accessory && service ? { accessory, service } : undefined; + } + + private static entityState(serviceArg: IHomekitService, serviceTypeArg: TKnownService): unknown { + if (serviceTypeArg === 'lightbulb' || serviceTypeArg === 'switch' || serviceTypeArg === 'outlet') { + return this.booleanState(this.charValue(serviceArg, 'on')) ? 'on' : 'off'; + } + if (serviceTypeArg === 'fan' || serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier') { + return this.booleanState(this.charValue(serviceArg, serviceTypeArg === 'fan' ? 'on' : 'active')) ? 'on' : 'off'; + } + if (serviceTypeArg === 'thermostat') { + return this.thermostatMode(this.charValue(serviceArg, 'heating_cooling_target')); + } + if (serviceTypeArg === 'heater_cooler') { + return this.numberValue(this.charValue(serviceArg, 'active')) === 0 ? 'off' : this.heaterCoolerMode(this.charValue(serviceArg, 'target_heater_cooler_state')); + } + if (serviceTypeArg === 'garage_door_opener') { + return this.garageDoorState(this.charValue(serviceArg, 'current_door_state')); + } + if (serviceTypeArg === 'window' || serviceTypeArg === 'window_covering') { + return this.coverState(serviceArg); + } + if (serviceTypeArg === 'lock_mechanism') { + return this.lockState(this.charValue(serviceArg, 'lock_current_state')); + } + if (this.binarySensorService(serviceTypeArg)) { + return this.binarySensorState(serviceArg, serviceTypeArg); + } + if (serviceTypeArg === 'camera_rtp_stream_management') { + return this.numberValue(this.charValue(serviceArg, 'active')) === 0 ? 'off' : 'available'; + } + return this.sensorState(serviceArg, serviceTypeArg); + } + + private static entityAttributes(accessoryArg: IHomekitAccessory, serviceArg: IHomekitService, serviceTypeArg: TKnownService): Record { + const info = this.accessoryInfo(accessoryArg); + const attributes: Record = { + aid: accessoryArg.aid, + serviceIid: serviceArg.iid, + serviceType: serviceTypeArg, + homekitServiceType: serviceArg.type, + characteristics: this.characteristicValues(serviceArg), + serialNumber: info.serialNumber, + }; + + const writable = this.primaryWritableCharacteristic(serviceArg, serviceTypeArg); + if (writable) { + attributes.writableCharacteristic = { aid: accessoryArg.aid, serviceIid: serviceArg.iid, iid: writable.iid, type: writable.type, characteristicType: this.characteristicType(writable.type) }; + } + if (serviceTypeArg === 'lightbulb') { + attributes.brightness = this.charValue(serviceArg, 'brightness'); + attributes.hue = this.charValue(serviceArg, 'hue'); + attributes.saturation = this.charValue(serviceArg, 'saturation'); + attributes.colorTemperature = this.charValue(serviceArg, 'color_temperature'); + } + if (serviceTypeArg === 'outlet') { + attributes.outletInUse = this.charValue(serviceArg, 'outlet_in_use'); + } + if (serviceTypeArg === 'fan' || serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier') { + attributes.percentage = this.charValue(serviceArg, 'rotation_speed'); + attributes.rotationDirection = this.charValue(serviceArg, 'rotation_direction'); + attributes.swingMode = this.charValue(serviceArg, 'swing_mode'); + } + if (serviceTypeArg === 'thermostat' || serviceTypeArg === 'heater_cooler') { + attributes.currentTemperature = this.charValue(serviceArg, 'current_temperature'); + attributes.targetTemperature = this.charValue(serviceArg, 'target_temperature'); + attributes.targetTemperatureHigh = this.charValue(serviceArg, 'cooling_threshold_temperature'); + attributes.targetTemperatureLow = this.charValue(serviceArg, 'heating_threshold_temperature'); + attributes.currentHumidity = this.charValue(serviceArg, 'current_humidity'); + attributes.targetHumidity = this.charValue(serviceArg, 'target_humidity'); + } + if (serviceTypeArg === 'window' || serviceTypeArg === 'window_covering') { + attributes.currentPosition = this.charValue(serviceArg, 'current_position'); + attributes.targetPosition = this.charValue(serviceArg, 'target_position'); + attributes.positionState = this.charValue(serviceArg, 'position_state'); + } + if (serviceTypeArg === 'garage_door_opener') { + attributes.obstructionDetected = this.charValue(serviceArg, 'obstruction_detected'); + } + if (serviceTypeArg === 'battery_service') { + attributes.unit = '%'; + attributes.lowBattery = this.numberValue(this.charValue(serviceArg, 'status_low_battery')) === 1; + attributes.chargingState = this.charValue(serviceArg, 'charging_state'); + } + return attributes; + } + + private static platformForService(serviceTypeArg: TKnownService): TEntityPlatform | undefined { + if (serviceTypeArg === 'lightbulb') { + return 'light'; + } + if (serviceTypeArg === 'switch' || serviceTypeArg === 'outlet') { + return 'switch'; + } + if (serviceTypeArg === 'thermostat' || serviceTypeArg === 'heater_cooler') { + return 'climate'; + } + if (serviceTypeArg === 'garage_door_opener' || serviceTypeArg === 'window' || serviceTypeArg === 'window_covering') { + return 'cover'; + } + if (serviceTypeArg === 'lock_mechanism') { + return 'lock' as TEntityPlatform; + } + if (serviceTypeArg === 'fan' || serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier') { + return 'fan'; + } + if (serviceTypeArg === 'camera_rtp_stream_management') { + return 'camera' as TEntityPlatform; + } + if (this.binarySensorService(serviceTypeArg)) { + return 'binary_sensor'; + } + if (this.sensorService(serviceTypeArg)) { + return 'sensor'; + } + return undefined; + } + + private static capabilityForPlatform(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability { + const platform = String(platformArg); + if (platform === 'light') { + return 'light'; + } + if (platform === 'cover') { + return 'cover'; + } + if (platform === 'lock') { + return 'lock'; + } + if (platform === 'fan') { + return 'fan'; + } + if (platform === 'climate') { + return 'climate'; + } + if (platform === 'camera') { + return 'camera'; + } + if (platform === 'switch') { + return 'switch'; + } + return 'sensor'; + } + + private static serviceWritable(serviceArg: IHomekitService, serviceTypeArg: TKnownService): boolean { + return Boolean(this.primaryWritableCharacteristic(serviceArg, serviceTypeArg)); + } + + private static primaryWritableCharacteristic(serviceArg: IHomekitService, serviceTypeArg: TKnownService, valueArg?: unknown): IHomekitCharacteristic | undefined { + if (serviceTypeArg === 'lightbulb' && typeof valueArg === 'number') { + return this.char(serviceArg, 'brightness') || this.char(serviceArg, 'on'); + } + const types = this.primaryWritableTypes(serviceTypeArg); + for (const type of types) { + const char = this.char(serviceArg, type); + if (char && this.characteristicWritable(char, type)) { + return char; + } + } + return undefined; + } + + private static primaryOnCharacteristic(serviceArg: IHomekitService, serviceTypeArg: TKnownService): IHomekitCharacteristic | undefined { + if (serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier') { + return this.char(serviceArg, 'active'); + } + if (serviceTypeArg === 'fan') { + return this.char(serviceArg, 'on'); + } + return this.char(serviceArg, 'on') || this.char(serviceArg, 'active'); + } + + private static primaryWritableTypes(serviceTypeArg: TKnownService): TKnownCharacteristic[] { + if (serviceTypeArg === 'lightbulb' || serviceTypeArg === 'switch' || serviceTypeArg === 'outlet' || serviceTypeArg === 'fan') { + return ['on']; + } + if (serviceTypeArg === 'fan_v2' || serviceTypeArg === 'air_purifier') { + return ['active', 'rotation_speed']; + } + if (serviceTypeArg === 'thermostat') { + return ['target_temperature', 'heating_cooling_target']; + } + if (serviceTypeArg === 'heater_cooler') { + return ['active', 'target_heater_cooler_state', 'heating_threshold_temperature', 'cooling_threshold_temperature']; + } + if (serviceTypeArg === 'garage_door_opener') { + return ['target_door_state']; + } + if (serviceTypeArg === 'window' || serviceTypeArg === 'window_covering') { + return ['target_position', 'hold_position']; + } + if (serviceTypeArg === 'lock_mechanism') { + return ['lock_target_state']; + } + return []; + } + + private static accessoryInfo(accessoryArg: IHomekitAccessory): IAccessoryInfo { + const infoService = this.accessoryServices(accessoryArg).find((serviceArg) => this.serviceType(serviceArg.type) === 'accessory_information'); + const name = this.stringValue(accessoryArg.name) + || this.stringValue(infoService ? this.charValue(infoService, 'name') : undefined) + || this.stringValue(accessoryArg.model) + || `HomeKit accessory ${accessoryArg.aid}`; + return { + name, + manufacturer: this.stringValue(accessoryArg.manufacturer) || this.stringValue(infoService ? this.charValue(infoService, 'manufacturer') : undefined), + model: this.stringValue(accessoryArg.model) || this.stringValue(infoService ? this.charValue(infoService, 'model') : undefined), + serialNumber: this.stringValue(accessoryArg.serialNumber) || this.stringValue(accessoryArg.serial_number) || this.stringValue(infoService ? this.charValue(infoService, 'serial_number') : undefined), + firmwareRevision: this.stringValue(accessoryArg.firmwareRevision) || this.stringValue(accessoryArg.firmware_revision) || this.stringValue(infoService ? this.charValue(infoService, 'firmware_revision') : undefined), + hardwareRevision: this.stringValue(accessoryArg.hardwareRevision) || this.stringValue(accessoryArg.hardware_revision) || this.stringValue(infoService ? this.charValue(infoService, 'hardware_revision') : undefined), + }; + } + + private static entityName(accessoryArg: IHomekitAccessory, serviceArg: IHomekitService, serviceTypeArg: TKnownService): string { + const accessoryName = this.accessoryInfo(accessoryArg).name; + const serviceName = this.stringValue(serviceArg.name) || this.stringValue(this.charValue(serviceArg, 'name')) || this.serviceTypeName(serviceTypeArg); + const foldedAccessory = this.foldedName(accessoryName); + const foldedService = this.foldedName(serviceName); + if (!serviceName || foldedAccessory === foldedService || foldedAccessory.includes(foldedService) || foldedService.includes(foldedAccessory)) { + return accessoryName; + } + return `${accessoryName} ${serviceName}`; + } + + private static serviceTypeName(serviceTypeArg: TKnownService): string { + return serviceTypeArg.split('_').map((partArg) => `${partArg.charAt(0).toUpperCase()}${partArg.slice(1)}`).join(' '); + } + + private static accessoryServices(accessoryArg: IHomekitAccessory): IHomekitService[] { + if (Array.isArray(accessoryArg.services)) { + return accessoryArg.services; + } + if (accessoryArg.services && typeof accessoryArg.services === 'object') { + return Object.values(accessoryArg.services); + } + return []; + } + + private static serviceCharacteristics(serviceArg: IHomekitService): IHomekitCharacteristic[] { + if (Array.isArray(serviceArg.characteristics)) { + return serviceArg.characteristics; + } + if (serviceArg.characteristics && typeof serviceArg.characteristics === 'object') { + return Object.values(serviceArg.characteristics); + } + if (serviceArg.characteristicsByType && typeof serviceArg.characteristicsByType === 'object') { + return Object.values(serviceArg.characteristicsByType); + } + if (serviceArg.characteristics_by_type && typeof serviceArg.characteristics_by_type === 'object') { + return Object.values(serviceArg.characteristics_by_type); + } + return []; + } + + private static char(serviceArg: IHomekitService, characteristicTypeArg: TKnownCharacteristic): IHomekitCharacteristic | undefined { + return this.serviceCharacteristics(serviceArg).find((charArg) => this.characteristicType(charArg.type) === characteristicTypeArg); + } + + private static charByType(serviceArg: IHomekitService, typeArg: string): IHomekitCharacteristic | undefined { + const characteristicType = this.characteristicType(typeArg); + return characteristicType === 'unknown' + ? this.serviceCharacteristics(serviceArg).find((charArg) => charArg.type === typeArg) + : this.char(serviceArg, characteristicType); + } + + private static charValue(serviceArg: IHomekitService, characteristicTypeArg: TKnownCharacteristic): unknown { + return this.char(serviceArg, characteristicTypeArg)?.value; + } + + private static characteristicValues(serviceArg: IHomekitService): Record { + const values: Record = {}; + for (const char of this.serviceCharacteristics(serviceArg)) { + const type = this.characteristicType(char.type); + values[type === 'unknown' ? char.type : type] = char.value; + } + return values; + } + + private static characteristicWritable(charArg: IHomekitCharacteristic, typeArg = this.characteristicType(charArg.type)): boolean { + if (Array.isArray(charArg.perms)) { + return charArg.perms.some((permArg) => ['pw', 'write', 'paired_write', 'write_response'].includes(permArg.toLowerCase())); + } + return writableCharacteristics.has(typeArg); + } + + private static serviceHasValues(serviceArg: IHomekitService): boolean { + return this.serviceCharacteristics(serviceArg).some((charArg) => charArg.value !== undefined && charArg.available !== false); + } + + private static binarySensorService(serviceTypeArg: TKnownService): boolean { + return ['carbon_monoxide_sensor', 'contact_sensor', 'leak_sensor', 'motion_sensor', 'occupancy_sensor', 'smoke_sensor'].includes(serviceTypeArg); + } + + private static sensorService(serviceTypeArg: TKnownService): boolean { + return ['air_quality_sensor', 'battery_service', 'carbon_dioxide_sensor', 'humidity_sensor', 'light_sensor', 'temperature_sensor'].includes(serviceTypeArg); + } + + private static binarySensorState(serviceArg: IHomekitService, serviceTypeArg: TKnownService): boolean | undefined { + if (serviceTypeArg === 'contact_sensor') { + const value = this.numberValue(this.charValue(serviceArg, 'contact_state')); + return value === undefined ? undefined : value === 1; + } + if (serviceTypeArg === 'motion_sensor') { + return this.booleanState(this.charValue(serviceArg, 'motion_detected')); + } + if (serviceTypeArg === 'smoke_sensor') { + return this.numberValue(this.charValue(serviceArg, 'smoke_detected')) === 1; + } + if (serviceTypeArg === 'carbon_monoxide_sensor') { + return this.numberValue(this.charValue(serviceArg, 'carbon_monoxide_detected')) === 1; + } + if (serviceTypeArg === 'carbon_dioxide_sensor') { + return this.numberValue(this.charValue(serviceArg, 'carbon_dioxide_detected')) === 1; + } + if (serviceTypeArg === 'leak_sensor') { + return this.numberValue(this.charValue(serviceArg, 'leak_detected')) === 1; + } + if (serviceTypeArg === 'occupancy_sensor') { + return this.numberValue(this.charValue(serviceArg, 'occupancy_detected')) === 1; + } + return undefined; + } + + private static sensorState(serviceArg: IHomekitService, serviceTypeArg: TKnownService): unknown { + if (serviceTypeArg === 'temperature_sensor') { + return this.charValue(serviceArg, 'current_temperature'); + } + if (serviceTypeArg === 'humidity_sensor') { + return this.charValue(serviceArg, 'current_humidity'); + } + if (serviceTypeArg === 'light_sensor') { + return this.charValue(serviceArg, 'current_light_level'); + } + if (serviceTypeArg === 'carbon_dioxide_sensor') { + return this.charValue(serviceArg, 'carbon_dioxide_level') ?? this.binarySensorState(serviceArg, serviceTypeArg); + } + if (serviceTypeArg === 'battery_service') { + return this.charValue(serviceArg, 'battery_level') ?? (this.numberValue(this.charValue(serviceArg, 'status_low_battery')) === 1 ? 'low' : 'normal'); + } + if (serviceTypeArg === 'air_quality_sensor') { + return this.airQualityState(this.charValue(serviceArg, 'air_quality')); + } + return 'unknown'; + } + + private static thermostatMode(valueArg: unknown): string { + const modes: Record = { 0: 'off', 1: 'heat', 2: 'cool', 3: 'heat_cool' }; + const value = this.numberValue(valueArg); + return value === undefined ? 'unknown' : modes[value] || 'unknown'; + } + + private static heaterCoolerMode(valueArg: unknown): string { + const modes: Record = { 0: 'heat_cool', 1: 'heat', 2: 'cool' }; + const value = this.numberValue(valueArg); + return value === undefined ? 'unknown' : modes[value] || 'unknown'; + } + + private static garageDoorState(valueArg: unknown): string { + const states: Record = { 0: 'open', 1: 'closed', 2: 'opening', 3: 'closing', 4: 'stopped' }; + const value = this.numberValue(valueArg); + return value === undefined ? 'unknown' : states[value] || 'unknown'; + } + + private static coverState(serviceArg: IHomekitService): string { + const position = this.numberValue(this.charValue(serviceArg, 'current_position')); + const positionState = this.numberValue(this.charValue(serviceArg, 'position_state')); + if (positionState === 0) { + return 'closing'; + } + if (positionState === 1) { + return 'opening'; + } + if (position === 0) { + return 'closed'; + } + if (position !== undefined) { + return 'open'; + } + return 'unknown'; + } + + private static lockState(valueArg: unknown): string { + const states: Record = { 0: 'unlocked', 1: 'locked', 2: 'jammed', 3: 'unknown' }; + const value = this.numberValue(valueArg); + return value === undefined ? 'unknown' : states[value] || 'unknown'; + } + + private static airQualityState(valueArg: unknown): string { + const states: Record = { 0: 'unknown', 1: 'excellent', 2: 'good', 3: 'fair', 4: 'inferior', 5: 'poor' }; + const value = this.numberValue(valueArg); + return value === undefined ? 'unknown' : states[value] || 'unknown'; + } + + private static referencesFromData(dataArg: Record | undefined): IHomekitCharacteristicReference[] { + const list = dataArg?.characteristics; + if (Array.isArray(list)) { + return list.map((itemArg) => this.referenceFromRecord(itemArg)).filter((itemArg): itemArg is IHomekitCharacteristicReference => Boolean(itemArg)); + } + const reference = this.referenceFromRecord(dataArg); + return reference ? [reference] : []; + } + + private static writesFromData(dataArg: Record | undefined): IHomekitCharacteristicWrite[] { + const list = dataArg?.characteristics; + if (Array.isArray(list)) { + return list.map((itemArg) => this.writeFromRecord(itemArg)).filter((itemArg): itemArg is IHomekitCharacteristicWrite => Boolean(itemArg)); + } + const write = this.writeFromRecord(dataArg); + return write ? [write] : []; + } + + private static referenceFromRecord(valueArg: unknown): IHomekitCharacteristicReference | undefined { + if (!this.isRecord(valueArg)) { + return undefined; + } + const aid = this.numberValue(valueArg.aid ?? valueArg.accessoryId); + const iid = this.numberValue(valueArg.iid ?? valueArg.instanceId); + if (aid === undefined || iid === undefined) { + return undefined; + } + return { + aid, + iid, + type: this.stringValue(valueArg.type ?? valueArg.characteristicType), + serviceIid: this.numberValue(valueArg.serviceIid), + serviceType: this.stringValue(valueArg.serviceType), + }; + } + + private static writeFromRecord(valueArg: unknown): IHomekitCharacteristicWrite | undefined { + if (!this.isRecord(valueArg) || !('value' in valueArg)) { + return undefined; + } + const reference = this.referenceFromRecord(valueArg); + return reference ? { ...reference, value: valueArg.value } : undefined; + } + + private static write(accessoryArg: IHomekitAccessory, serviceArg: IHomekitService, charArg: IHomekitCharacteristic, valueArg: unknown): IHomekitCharacteristicWrite { + return { + aid: accessoryArg.aid, + serviceIid: serviceArg.iid, + serviceType: serviceArg.type, + iid: charArg.iid, + type: charArg.type, + value: valueArg, + }; + } + + private static deviceId(snapshotArg: IHomekitSnapshot, accessoryArg: IHomekitAccessory): string { + return `homekit_controller.accessory.${this.slug(`${snapshotArg.id || snapshotArg.pairingData?.AccessoryPairingID || 'snapshot'}_${accessoryArg.aid}`)}`; + } + + private static typeToken(valueArg?: string): string { + const value = (valueArg || '').trim().toLowerCase(); + const uuidMatch = /^([0-9a-f]{8})-0000-1000-8000-0026bb765291$/.exec(value); + if (uuidMatch) { + return uuidMatch[1]; + } + if (/^[0-9a-f]{1,8}$/.test(value)) { + return value.padStart(8, '0'); + } + return value.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + } + + private static booleanState(valueArg: unknown): boolean { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + if (typeof valueArg === 'string') { + return ['1', 'true', 'on', 'active'].includes(valueArg.toLowerCase()); + } + return false; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number(valueArg); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } + + private static brightnessValue(dataArg?: Record): number | undefined { + const percentage = this.numberValue(dataArg?.brightnessPct ?? dataArg?.brightnessPercentage ?? dataArg?.brightness_percentage ?? dataArg?.percentage); + if (percentage !== undefined) { + return this.clampPercent(percentage); + } + const brightness = this.numberValue(dataArg?.brightness); + return brightness === undefined ? undefined : this.clampPercent(Math.round(brightness * 100 / 255)); + } + + private static clampPercent(valueArg: number): number { + return Math.max(0, Math.min(100, valueArg)); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static foldedName(valueArg: string): string { + return valueArg.toLowerCase().replace(/\s+/g, ''); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'homekit_controller'; + } +} diff --git a/ts/integrations/homekit_controller/homekit_controller.types.ts b/ts/integrations/homekit_controller/homekit_controller.types.ts index c946127..847b7f7 100644 --- a/ts/integrations/homekit_controller/homekit_controller.types.ts +++ b/ts/integrations/homekit_controller/homekit_controller.types.ts @@ -1,4 +1,181 @@ -export interface IHomeAssistantHomekitControllerConfig { - // TODO: replace with the TypeScript-native config for homekit_controller. +export type THomekitTransport = 'ip' | 'ble' | 'thread' | 'coap' | 'snapshot' | 'unknown'; + +export type THomekitControllerCommandType = + | 'snapshot' + | 'pair_setup' + | 'pair_verify' + | 'read_characteristics' + | 'write_characteristics' + | 'subscribe_events' + | 'identify' + | 'camera_snapshot'; + +export interface IHomekitControllerConfig { + id?: string; + name?: string; + host?: string; + port?: number; + setupCode?: string; + allowInsecureSetupCode?: boolean; + pairingData?: IHomekitPairingData; + paired?: boolean; + transport?: THomekitTransport; + model?: string; + manufacturer?: string; + category?: string | number; + configNumber?: number; + stateNumber?: number; + accessories?: IHomekitAccessory[]; + snapshot?: IHomekitSnapshot; + discovery?: IHomekitDiscoveryRecord; + connected?: boolean; +} + +export interface IHomeAssistantHomekitControllerConfig extends IHomekitControllerConfig {} + +export interface IHomekitPairingData { + AccessoryPairingID?: string; + AccessoryIP?: string; + AccessoryIPs?: string[]; + AccessoryPort?: number; + AccessoryAddress?: string; + Connection?: string; + iOSPairingId?: string; + iOSDeviceLTSK?: string; + iOSDeviceLTPK?: string; + AccessoryLTPK?: string; [key: string]: unknown; } + +export interface IHomekitDiscoveryRecord { + source?: 'mdns' | 'manual' | 'bluetooth' | 'snapshot'; + id?: string; + name?: string; + host?: string; + port?: number; + model?: string; + manufacturer?: string; + category?: string | number; + paired?: boolean; + configNumber?: number; + stateNumber?: number; + statusFlags?: number; + setupHash?: string; + protocolVersion?: string; + txt?: Record; +} + +export interface IHomekitMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface IHomekitManualEntry { + id?: string; + name?: string; + host?: string; + port?: number; + setupCode?: string; + pairingData?: IHomekitPairingData; + accessories?: IHomekitAccessory[]; + snapshot?: IHomekitSnapshot; + model?: string; + manufacturer?: string; + category?: string | number; + metadata?: Record; +} + +export interface IHomekitSnapshot { + id?: string; + name?: string; + host?: string; + port?: number; + paired?: boolean; + connected: boolean; + transport?: THomekitTransport; + configNumber?: number; + stateNumber?: number; + pairingData?: IHomekitPairingData; + discovery?: IHomekitDiscoveryRecord; + accessories: IHomekitAccessory[]; + events?: IHomekitEvent[]; +} + +export interface IHomekitAccessory { + aid: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareRevision?: string; + hardwareRevision?: string; + services?: IHomekitService[] | Record; + [key: string]: unknown; +} + +export interface IHomekitService { + iid: number; + type: string; + name?: string; + primary?: boolean; + hidden?: boolean; + characteristics?: IHomekitCharacteristic[] | Record; + characteristicsByType?: Record; + characteristics_by_type?: Record; + [key: string]: unknown; +} + +export interface IHomekitCharacteristic { + iid: number; + type: string; + value?: unknown; + description?: string; + name?: string; + perms?: string[]; + format?: string; + unit?: string; + minValue?: number; + maxValue?: number; + minStep?: number; + maxLen?: number; + validValues?: unknown[]; + available?: boolean; + [key: string]: unknown; +} + +export interface IHomekitCharacteristicReference { + aid: number; + iid: number; + type?: string; + serviceIid?: number; + serviceType?: string; +} + +export interface IHomekitCharacteristicWrite extends IHomekitCharacteristicReference { + value: unknown; +} + +export interface IHomekitControllerCommand { + command: THomekitControllerCommandType; + reads?: IHomekitCharacteristicReference[]; + writes?: IHomekitCharacteristicWrite[]; + aid?: number; + width?: number; + height?: number; +} + +export interface IHomekitEvent { + type: 'snapshot' | 'characteristic_updated' | 'availability_changed' | 'config_changed' | 'unsupported' | 'error'; + aid?: number; + iid?: number; + value?: unknown; + data?: unknown; + timestamp: number; +} diff --git a/ts/integrations/homekit_controller/index.ts b/ts/integrations/homekit_controller/index.ts index 61a3574..26633a0 100644 --- a/ts/integrations/homekit_controller/index.ts +++ b/ts/integrations/homekit_controller/index.ts @@ -1,2 +1,6 @@ +export * from './homekit_controller.classes.client.js'; +export * from './homekit_controller.classes.configflow.js'; export * from './homekit_controller.classes.integration.js'; +export * from './homekit_controller.discovery.js'; +export * from './homekit_controller.mapper.js'; export * from './homekit_controller.types.js'; diff --git a/ts/integrations/matter/.generated-by-smarthome-exchange b/ts/integrations/matter/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/matter/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/matter/index.ts b/ts/integrations/matter/index.ts index 841a82a..1b81c6e 100644 --- a/ts/integrations/matter/index.ts +++ b/ts/integrations/matter/index.ts @@ -1,2 +1,6 @@ export * from './matter.classes.integration.js'; +export * from './matter.classes.client.js'; +export * from './matter.classes.configflow.js'; +export * from './matter.discovery.js'; +export * from './matter.mapper.js'; export * from './matter.types.js'; diff --git a/ts/integrations/matter/matter.classes.client.ts b/ts/integrations/matter/matter.classes.client.ts new file mode 100644 index 0000000..c7919f1 --- /dev/null +++ b/ts/integrations/matter/matter.classes.client.ts @@ -0,0 +1,437 @@ +import * as plugins from '../../plugins.js'; +import type { + IMatterCommandFrame, + IMatterConfig, + IMatterErrorFrame, + IMatterNode, + IMatterResultFrame, + IMatterServerCommand, + IMatterServerEvent, + IMatterServerInfo, + IMatterSnapshot, +} from './matter.types.js'; + +const defaultMatterServerUrl = 'ws://localhost:5580/ws'; +const defaultCommandTimeoutMs = 300000; +const bigIntMarker = '__BIGINT__'; + +type TMatterEventHandler = (eventArg: IMatterServerEvent) => void; + +interface IPendingRequest { + resolve(valueArg: unknown): void; + reject(errorArg: Error): void; + timer: ReturnType; +} + +interface IPendingServerInfo { + resolve(): void; + reject(errorArg: Error): void; + timer: ReturnType; +} + +export class MatterClient { + private socket?: any; + private started = false; + private serverInfo?: IMatterServerInfo; + private readonly nodes = new Map(); + private readonly events: IMatterServerEvent[] = []; + private readonly pendingRequests = new Map(); + private readonly eventHandlers = new Set(); + private pendingServerInfo?: IPendingServerInfo; + private messageCounter = Math.floor(Math.random() * 0x7fffffff); + + constructor(private readonly config: IMatterConfig) { + this.serverInfo = config.snapshot?.serverInfo || config.serverInfo; + for (const node of config.snapshot?.nodes || config.nodes || []) { + this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node)); + } + for (const event of config.snapshot?.events || config.events || []) { + this.events.push(event); + } + } + + public async getSnapshot(): Promise { + if (this.hasLocalSnapshot() && this.config.connect !== true) { + return { + serverInfo: this.config.snapshot?.serverInfo || this.serverInfo, + nodes: (this.config.snapshot?.nodes || [...this.nodes.values()]).map((nodeArg) => this.cloneNode(nodeArg)), + events: [...(this.config.snapshot?.events || this.events)], + connected: false, + url: this.url(), + }; + } + if (this.url() && !this.started) { + await this.start(); + } + return { + serverInfo: this.serverInfo, + nodes: [...this.nodes.values()].map((nodeArg) => this.cloneNode(nodeArg)), + events: [...this.events], + connected: Boolean(this.socket?.readyState === 1), + url: this.url(), + }; + } + + public async start(): Promise { + if (this.started) { + return; + } + const url = this.url(); + if (!url || (this.hasLocalSnapshot() && this.config.connect !== true)) { + this.started = true; + return; + } + await this.connect(url); + const nodes = await this.sendConnectedCommand({ command: 'start_listening', args: {} }); + this.nodes.clear(); + if (Array.isArray(nodes)) { + for (const node of nodes) { + this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node)); + } + } + this.started = true; + } + + public async sendCommand(commandArg: IMatterServerCommand): Promise { + await this.ensureStarted(); + return this.sendConnectedCommand(commandArg); + } + + public onEvent(handlerArg: TMatterEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async destroy(): Promise { + this.rejectAll(new Error('Matter client destroyed.')); + if (this.pendingServerInfo) { + clearTimeout(this.pendingServerInfo.timer); + this.pendingServerInfo.reject(new Error('Matter client destroyed.')); + this.pendingServerInfo = undefined; + } + if (this.socket?.readyState === 1 || this.socket?.readyState === 0) { + this.socket.close(); + } + this.socket = undefined; + this.started = false; + this.eventHandlers.clear(); + } + + private async ensureStarted(): Promise { + if (!this.started) { + await this.start(); + } + if (!this.socket || this.socket.readyState !== 1) { + throw new Error('Matter Server WebSocket is not connected.'); + } + } + + private async connect(urlArg: string): Promise { + if (this.socket?.readyState === 1) { + return; + } + const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: new (urlArg: string) => any }).WebSocket; + if (!WebSocketCtor) { + throw new Error('Global WebSocket is not available in this runtime.'); + } + + const socket = new WebSocketCtor(urlArg); + this.socket = socket; + this.addSocketEvent(socket, 'message', (eventArg: { data: unknown }) => this.handleMessage(eventArg.data)); + this.addSocketEvent(socket, 'close', () => this.handleClose()); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingServerInfo = undefined; + reject(new Error(`Matter Server at ${urlArg} did not send server info.`)); + }, 10000); + this.pendingServerInfo = { resolve, reject, timer }; + this.addSocketEvent(socket, 'error', () => { + if (this.pendingServerInfo) { + clearTimeout(this.pendingServerInfo.timer); + this.pendingServerInfo = undefined; + } + reject(new Error(`Unable to connect to Matter Server at ${urlArg}.`)); + }, { once: true }); + }); + } + + private sendConnectedCommand(commandArg: IMatterServerCommand): Promise { + if (!this.socket || this.socket.readyState !== 1) { + throw new Error('Matter Server WebSocket is not connected.'); + } + if (commandArg.requireSchema && this.serverInfo && this.serverInfo.schema_version < commandArg.requireSchema) { + throw new Error(`Matter command ${commandArg.command} requires schema ${commandArg.requireSchema}.`); + } + const messageId = this.nextMessageId(); + const frame: IMatterCommandFrame = { + message_id: messageId, + command: commandArg.command, + args: commandArg.args || {}, + }; + const timeoutMs = commandArg.timeoutMs ?? this.config.commandTimeoutMs ?? defaultCommandTimeoutMs; + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(messageId); + reject(new Error(`Matter command ${commandArg.command} timed out.`)); + }, timeoutMs); + this.pendingRequests.set(messageId, { resolve: resolve as (valueArg: unknown) => void, reject, timer }); + }); + this.socket.send(toMatterJson(frame)); + return promise; + } + + private handleMessage(dataArg: unknown): void { + const message = parseMatterMessage(dataArg); + if (!this.isRecord(message)) { + return; + } + if (this.isServerInfo(message) && !('message_id' in message)) { + this.serverInfo = message; + if (this.pendingServerInfo) { + clearTimeout(this.pendingServerInfo.timer); + const pending = this.pendingServerInfo; + this.pendingServerInfo = undefined; + pending.resolve(); + } + return; + } + if (typeof message.event === 'string') { + this.handleEvent(message as unknown as IMatterServerEvent); + return; + } + if (typeof message.message_id === 'string' && 'error_code' in message) { + this.rejectPending(message as unknown as IMatterErrorFrame); + return; + } + if (typeof message.message_id === 'string' && 'result' in message) { + this.resolvePending(message as unknown as IMatterResultFrame); + } + } + + private handleEvent(eventArg: IMatterServerEvent): void { + this.events.push(eventArg); + if ((eventArg.event === 'node_added' || eventArg.event === 'node_updated') && this.isMatterNode(eventArg.data)) { + this.nodes.set(this.nodeKey(eventArg.data.node_id), this.cloneNode(eventArg.data)); + } else if (eventArg.event === 'node_removed') { + this.nodes.delete(this.nodeKey(eventArg.data)); + } else if (eventArg.event === 'attribute_updated' && Array.isArray(eventArg.data)) { + const [nodeId, attributePath, value] = eventArg.data; + if (typeof attributePath === 'string') { + const node = this.nodes.get(this.nodeKey(nodeId)); + if (node) { + const updated = this.cloneNode(node); + updated.attributes[attributePath] = value; + this.nodes.set(this.nodeKey(nodeId), updated); + } + } + } else if (eventArg.event === 'server_info_updated' && this.isServerInfo(eventArg.data)) { + this.serverInfo = eventArg.data; + } + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private resolvePending(frameArg: IMatterResultFrame): void { + const pending = this.pendingRequests.get(frameArg.message_id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(frameArg.message_id); + pending.resolve(frameArg.result); + } + + private rejectPending(frameArg: IMatterErrorFrame): void { + const pending = this.pendingRequests.get(frameArg.message_id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(frameArg.message_id); + pending.reject(new Error(frameArg.details || `Matter command failed with error ${frameArg.error_code}.`)); + } + + private handleClose(): void { + if (this.pendingServerInfo) { + clearTimeout(this.pendingServerInfo.timer); + this.pendingServerInfo.reject(new Error('Matter Server WebSocket closed before server info.')); + this.pendingServerInfo = undefined; + } + this.rejectAll(new Error('Matter Server WebSocket closed.')); + this.socket = undefined; + this.started = false; + } + + private rejectAll(errorArg: Error): void { + for (const [messageId, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(errorArg); + this.pendingRequests.delete(messageId); + } + } + + private addSocketEvent(socketArg: any, typeArg: string, handlerArg: (eventArg: any) => void, optionsArg?: { once?: boolean }): void { + if (typeof socketArg.addEventListener === 'function') { + socketArg.addEventListener(typeArg, handlerArg, optionsArg); + return; + } + const property = `on${typeArg}`; + const previous = socketArg[property]; + socketArg[property] = optionsArg?.once ? (eventArg: any) => { + socketArg[property] = previous; + handlerArg(eventArg); + } : handlerArg; + } + + private nextMessageId(): string { + if (this.messageCounter >= Number.MAX_SAFE_INTEGER) { + this.messageCounter = 0; + } + return `shx-${++this.messageCounter}-${plugins.crypto.randomBytes(3).toString('hex')}`; + } + + private url(): string | undefined { + if (this.config.url) { + return this.config.url; + } + if (this.config.host) { + return `ws://${this.config.host}:${this.config.port || 5580}/ws`; + } + return this.hasLocalSnapshot() ? undefined : defaultMatterServerUrl; + } + + private hasLocalSnapshot(): boolean { + return Boolean(this.config.snapshot || this.config.nodes?.length || this.config.serverInfo || this.config.events?.length); + } + + private cloneNode(nodeArg: IMatterNode): IMatterNode { + return { ...nodeArg, attributes: { ...nodeArg.attributes } }; + } + + private nodeKey(valueArg: unknown): string { + return String(valueArg); + } + + private isMatterNode(valueArg: unknown): valueArg is IMatterNode { + return this.isRecord(valueArg) && 'node_id' in valueArg && this.isRecord(valueArg.attributes); + } + + private isServerInfo(valueArg: unknown): valueArg is IMatterServerInfo { + return this.isRecord(valueArg) + && typeof valueArg.schema_version === 'number' + && typeof valueArg.min_supported_schema_version === 'number' + && typeof valueArg.sdk_version === 'string'; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} + +function parseMatterMessage(dataArg: unknown): unknown { + const text = typeof dataArg === 'string' + ? dataArg + : Buffer.isBuffer(dataArg) + ? dataArg.toString('utf8') + : dataArg instanceof ArrayBuffer + ? Buffer.from(dataArg).toString('utf8') + : String(dataArg); + return parseBigIntAwareJson(text); +} + +function toMatterJson(valueArg: unknown): string { + const replacements: Array<{ from: string; to: string }> = []; + let result = JSON.stringify(valueArg, (_key, value) => { + if (typeof value === 'bigint') { + if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) { + replacements.push({ from: `"0x${value.toString(16)}"`, to: value.toString() }); + return `0x${value.toString(16)}`; + } + return Number(value); + } + return value; + }); + for (const replacement of replacements) { + result = result.replaceAll(replacement.from, replacement.to); + } + return result; +} + +function parseBigIntAwareJson(jsonArg: string): unknown { + const result: string[] = []; + let index = 0; + let inString = false; + while (index < jsonArg.length) { + const char = jsonArg[index]; + if (inString) { + if (char === '\\') { + result.push(char); + index++; + if (index < jsonArg.length) { + result.push(jsonArg[index]); + index++; + } + } else if (char === '"') { + result.push(char); + inString = false; + index++; + } else { + result.push(char); + index++; + } + continue; + } + if (char === '"') { + result.push(char); + inString = true; + index++; + continue; + } + if (char >= '0' && char <= '9') { + const hasMinus = result.length > 0 && result[result.length - 1] === '-'; + if (hasMinus) { + result.pop(); + } + const start = index; + while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') { + index++; + } + let isFloat = false; + if (jsonArg[index] === '.') { + isFloat = true; + index++; + while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') { + index++; + } + } + if (jsonArg[index] === 'e' || jsonArg[index] === 'E') { + isFloat = true; + index++; + if (jsonArg[index] === '+' || jsonArg[index] === '-') { + index++; + } + while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') { + index++; + } + } + const numberString = `${hasMinus ? '-' : ''}${jsonArg.slice(start, index)}`; + if (!isFloat && numberString.length - (hasMinus ? 1 : 0) >= 15) { + const value = BigInt(numberString); + result.push(value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER ? `"${bigIntMarker}${numberString}"` : numberString); + } else { + result.push(numberString); + } + continue; + } + result.push(char); + index++; + } + return JSON.parse(result.join(''), (_key, value) => { + if (typeof value === 'string' && value.startsWith(bigIntMarker)) { + return BigInt(value.slice(bigIntMarker.length)); + } + return value; + }); +} diff --git a/ts/integrations/matter/matter.classes.configflow.ts b/ts/integrations/matter/matter.classes.configflow.ts new file mode 100644 index 0000000..4cd6589 --- /dev/null +++ b/ts/integrations/matter/matter.classes.configflow.ts @@ -0,0 +1,40 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IMatterConfig } from './matter.types.js'; + +const defaultMatterServerUrl = 'ws://localhost:5580/ws'; + +export class MatterConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaultUrl = this.urlFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Connect Matter', + description: `Configure the local Matter Server WebSocket endpoint. Default: ${defaultUrl}`, + fields: [ + { name: 'url', label: 'Matter Server URL', type: 'text', required: true }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'Matter configured', + config: { + url: this.stringValue(valuesArg.url) || defaultUrl, + }, + }), + }; + } + + private urlFromCandidate(candidateArg: IDiscoveryCandidate): string { + if (typeof candidateArg.metadata?.url === 'string' && candidateArg.metadata.url.trim()) { + return candidateArg.metadata.url.trim(); + } + if (candidateArg.host) { + return `ws://${candidateArg.host}:${candidateArg.port || 5580}/ws`; + } + return defaultMatterServerUrl; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } +} diff --git a/ts/integrations/matter/matter.classes.integration.ts b/ts/integrations/matter/matter.classes.integration.ts index 7b3ea6b..a586514 100644 --- a/ts/integrations/matter/matter.classes.integration.ts +++ b/ts/integrations/matter/matter.classes.integration.ts @@ -1,30 +1,203 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { MatterClient } from './matter.classes.client.js'; +import { MatterConfigFlow } from './matter.classes.configflow.js'; +import { createMatterDiscoveryDescriptor } from './matter.discovery.js'; +import { MatterMapper } from './matter.mapper.js'; +import type { IMatterConfig, IMatterServerCommand, TMatterNodeId } from './matter.types.js'; -export class HomeAssistantMatterIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "matter", - displayName: "Matter", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/matter", - "upstreamDomain": "matter", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "matter-python-client==0.6.0" - ], - "dependencies": [ - "websocket_api" - ], - "afterDependencies": [ - "hassio" - ], - "codeowners": [ - "@home-assistant/matter" - ] -}, +export class MatterIntegration extends BaseIntegration { + public readonly domain = 'matter'; + public readonly displayName = 'Matter'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createMatterDiscoveryDescriptor(); + public readonly configFlow = new MatterConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + domain: 'matter', + name: 'Matter', + upstreamPath: 'homeassistant/components/matter', + upstreamDomain: 'matter', + configFlow: true, + integrationType: 'hub', + iotClass: 'local_push', + requirements: ['matter-python-client==0.6.0'], + dependencies: ['websocket_api'], + afterDependencies: ['hassio'], + codeowners: ['@home-assistant/matter'], + documentation: 'https://www.home-assistant.io/integrations/matter', + zeroconf: ['_matter._tcp.local.', '_matterc._udp.local.'], + }; + + public async setup(configArg: IMatterConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new MatterRuntime(new MatterClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantMatterIntegration extends MatterIntegration {} + +class MatterRuntime implements IIntegrationRuntime { + public domain = 'matter'; + + constructor(private readonly client: MatterClient) {} + + public async devices(): Promise { + return MatterMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return MatterMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: this.integrationEventType(eventArg.event), + integrationDomain: 'matter', + deviceId: this.deviceIdFromEvent(eventArg.data), + data: eventArg, + timestamp: Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = requestArg.domain === 'matter' + ? this.commandFromMatterService(requestArg) + : MatterMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported Matter service: ${requestArg.domain}.${requestArg.service}` }; + } + const data = await this.client.sendCommand(command); + return { success: true, data }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromMatterService(requestArg: IServiceCallRequest): IMatterServerCommand | undefined { + if (requestArg.service === 'commission_with_code') { + const code = this.stringValue(requestArg.data?.code); + return code ? { command: 'commission_with_code', args: { code, network_only: requestArg.data?.network_only === true || requestArg.data?.networkOnly === true }, timeoutMs: 600000 } : undefined; + } + if (requestArg.service === 'commission_on_network') { + const setupPinCode = this.numberValue(requestArg.data?.setup_pin_code ?? requestArg.data?.setupPinCode); + if (setupPinCode === undefined) { + return undefined; + } + return { + command: 'commission_on_network', + args: { + setup_pin_code: setupPinCode, + filter_type: this.numberValue(requestArg.data?.filter_type ?? requestArg.data?.filterType), + filter: this.numberValue(requestArg.data?.filter), + ip_addr: this.stringValue(requestArg.data?.ip_addr ?? requestArg.data?.ipAddr), + }, + timeoutMs: 600000, + }; + } + if (requestArg.service === 'open_commissioning_window') { + const nodeId = this.nodeIdFromRequest(requestArg); + return nodeId === undefined ? undefined : { + command: 'open_commissioning_window', + args: { + node_id: nodeId, + timeout: this.numberValue(requestArg.data?.timeout), + iteration: this.numberValue(requestArg.data?.iteration), + option: this.numberValue(requestArg.data?.option), + discriminator: this.numberValue(requestArg.data?.discriminator), + }, + timeoutMs: 600000, + }; + } + if (requestArg.service === 'remove_node') { + const nodeId = this.nodeIdFromRequest(requestArg); + return nodeId === undefined ? undefined : { command: 'remove_node', args: { node_id: nodeId }, timeoutMs: 600000 }; + } + if (requestArg.service === 'interview_node') { + const nodeId = this.nodeIdFromRequest(requestArg); + return nodeId === undefined ? undefined : { command: 'interview_node', args: { node_id: nodeId }, timeoutMs: 600000 }; + } + if (requestArg.service === 'ping_node') { + const nodeId = this.nodeIdFromRequest(requestArg); + return nodeId === undefined ? undefined : { command: 'ping_node', args: { node_id: nodeId, attempts: this.numberValue(requestArg.data?.attempts) } }; + } + if (requestArg.service === 'set_default_fabric_label') { + const label = requestArg.data?.label; + return label === null || typeof label === 'string' ? { command: 'set_default_fabric_label', requireSchema: 11, args: { label } } : undefined; + } + return undefined; + } + + private integrationEventType(eventArg: string): IIntegrationEvent['type'] { + if (eventArg === 'node_added' || eventArg === 'endpoint_added') { + return 'device_added'; + } + if (eventArg === 'node_removed' || eventArg === 'endpoint_removed') { + return 'device_removed'; + } + if (eventArg === 'node_updated') { + return 'availability_changed'; + } + if (eventArg === 'attribute_updated' || eventArg === 'node_event') { + return 'state_changed'; + } + return eventArg === 'server_shutdown' ? 'error' : 'state_changed'; + } + + private deviceIdFromEvent(dataArg: unknown): string | undefined { + if (Array.isArray(dataArg)) { + return `matter.node.${this.slug(String(dataArg[0]))}`; + } + if (this.isRecord(dataArg)) { + const nodeId = dataArg.node_id ?? dataArg.nodeId; + const endpointId = this.numberValue(dataArg.endpoint_id ?? dataArg.endpointId); + if (nodeId !== undefined) { + return endpointId === undefined ? `matter.node.${this.slug(String(nodeId))}` : `matter.node.${this.slug(String(nodeId))}.endpoint.${endpointId}`; + } + } + if (typeof dataArg === 'number' || typeof dataArg === 'bigint' || typeof dataArg === 'string') { + return `matter.node.${this.slug(String(dataArg))}`; + } + return undefined; + } + + private nodeIdFromRequest(requestArg: IServiceCallRequest): TMatterNodeId | undefined { + const value = requestArg.data?.node_id ?? requestArg.data?.nodeId; + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value; + } + const deviceId = requestArg.target.deviceId; + const match = deviceId?.match(/^matter\.node\.([^.]+)/); + return match?.[1]; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'matter'; } } diff --git a/ts/integrations/matter/matter.discovery.ts b/ts/integrations/matter/matter.discovery.ts new file mode 100644 index 0000000..9e59118 --- /dev/null +++ b/ts/integrations/matter/matter.discovery.ts @@ -0,0 +1,111 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IMatterManualEntry, IMatterMdnsRecord } from './matter.types.js'; + +const defaultMatterServerUrl = 'ws://localhost:5580/ws'; + +export class MatterMdnsMatcher implements IDiscoveryMatcher { + public id = 'matter-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Matter zeroconf advertisements.'; + + public async matches(recordArg: IMatterMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const matched = type === '_matter._tcp.local' || type === '_matterc._udp.local'; + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Matter advertisement.' }; + } + const instanceName = this.txt(recordArg, 'dn') || this.txt(recordArg, 'D') || recordArg.name; + return { + matched: true, + confidence: 'high', + reason: 'mDNS record matches Matter zeroconf metadata.', + normalizedDeviceId: recordArg.name || recordArg.host, + candidate: { + source: 'mdns', + integrationDomain: 'matter', + id: recordArg.name || recordArg.host, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port, + name: instanceName || 'Matter device', + manufacturer: 'Matter', + model: type === '_matterc._udp.local' ? 'Commissionable Matter device' : 'Matter device', + metadata: { + matter: true, + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + url: this.txt(recordArg, 'url'), + }, + }, + }; + } + + private txt(recordArg: IMatterMdnsRecord, keyArg: string): string | undefined { + return recordArg.txt?.[keyArg] || recordArg.txt?.[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } +} + +export class MatterManualMatcher implements IDiscoveryMatcher { + public id = 'matter-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Matter Server setup entries.'; + + public async matches(inputArg: IMatterManualEntry): Promise { + const model = inputArg.model?.toLowerCase() || ''; + const url = inputArg.url || (inputArg.host ? `ws://${inputArg.host}:${inputArg.port || 5580}/ws` : undefined); + const matched = Boolean(url || inputArg.metadata?.matter || model.includes('matter')) || !inputArg.id && !inputArg.model; + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Matter setup hints.' }; + } + return { + matched: true, + confidence: url ? 'high' : 'medium', + reason: 'Manual entry can start Matter Server setup.', + normalizedDeviceId: inputArg.id || url || defaultMatterServerUrl, + candidate: { + source: 'manual', + integrationDomain: 'matter', + id: inputArg.id || url || defaultMatterServerUrl, + host: inputArg.host, + port: inputArg.port || 5580, + name: inputArg.name || 'Matter', + manufacturer: 'Matter', + model: inputArg.model || 'Matter Server', + metadata: { ...inputArg.metadata, matter: true, url: url || defaultMatterServerUrl }, + }, + }; + } +} + +export class MatterCandidateValidator implements IDiscoveryValidator { + public id = 'matter-candidate-validator'; + public description = 'Validate Matter candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const model = candidateArg.model?.toLowerCase() || ''; + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'matter' + || Boolean(candidateArg.metadata?.matter) + || model.includes('matter') + || manufacturer.includes('matter'); + return { + matched, + confidence: matched && (candidateArg.host || candidateArg.metadata?.url) ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Matter metadata.' : 'Candidate is not Matter.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createMatterDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'matter', displayName: 'Matter' }) + .addMatcher(new MatterMdnsMatcher()) + .addMatcher(new MatterManualMatcher()) + .addValidator(new MatterCandidateValidator()); +}; diff --git a/ts/integrations/matter/matter.mapper.ts b/ts/integrations/matter/matter.mapper.ts new file mode 100644 index 0000000..d34f59e --- /dev/null +++ b/ts/integrations/matter/matter.mapper.ts @@ -0,0 +1,873 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IMatterAttribute, + IMatterCluster, + IMatterConfig, + IMatterDeviceInfo, + IMatterDeviceType, + IMatterEndpoint, + IMatterNode, + IMatterServerCommand, + IMatterServerEvent, + IMatterServiceCommandArgs, + IMatterSnapshot, + TMatterEntityPlatform, + TMatterNodeId, +} from './matter.types.js'; + +const clusters = { + descriptor: 29, + powerSource: 47, + bridgedDeviceBasicInformation: 57, + booleanState: 69, + airQuality: 91, + smokeCoAlarm: 92, + onOff: 6, + levelControl: 8, + basicInformation: 40, + doorLock: 257, + windowCovering: 258, + thermostat: 513, + colorControl: 768, + illuminanceMeasurement: 1024, + temperatureMeasurement: 1026, + pressureMeasurement: 1027, + flowMeasurement: 1028, + relativeHumidityMeasurement: 1029, + occupancySensing: 1030, +} as const; + +const globalAttributes = { + acceptedCommandList: 65529, + attributeList: 65531, + featureMap: 65532, + clusterRevision: 65533, +} as const; + +const deviceTypes: Record = { + 0x000a: 'Door Lock', + 0x000e: 'Aggregator', + 0x000f: 'Generic Switch', + 0x0013: 'Bridged Node', + 0x0016: 'Root Node', + 0x0015: 'Contact Sensor', + 0x002c: 'Air Quality Sensor', + 0x0041: 'Water Freeze Detector', + 0x0043: 'Water Leak Detector', + 0x0044: 'Rain Sensor', + 0x0072: 'Room Air Conditioner', + 0x0076: 'Smoke CO Alarm', + 0x0100: 'On/Off Light', + 0x0101: 'Dimmable Light', + 0x0103: 'On/Off Light Switch', + 0x0104: 'Dimmer Switch', + 0x0105: 'Color Dimmer Switch', + 0x0106: 'Light Sensor', + 0x0107: 'Occupancy Sensor', + 0x010a: 'On/Off Plug-in Unit', + 0x010b: 'Dimmable Plug-in Unit', + 0x010c: 'Color Temperature Light', + 0x010d: 'Extended Color Light', + 0x0202: 'Window Covering', + 0x0301: 'Thermostat', + 0x0302: 'Temperature Sensor', + 0x0305: 'Pressure Sensor', + 0x0306: 'Flow Sensor', + 0x0307: 'Humidity Sensor', +}; + +const clusterNames: Record = { + [clusters.descriptor]: 'Descriptor', + [clusters.powerSource]: 'PowerSource', + [clusters.bridgedDeviceBasicInformation]: 'BridgedDeviceBasicInformation', + [clusters.booleanState]: 'BooleanState', + [clusters.airQuality]: 'AirQuality', + [clusters.smokeCoAlarm]: 'SmokeCoAlarm', + [clusters.onOff]: 'OnOff', + [clusters.levelControl]: 'LevelControl', + [clusters.basicInformation]: 'BasicInformation', + [clusters.doorLock]: 'DoorLock', + [clusters.windowCovering]: 'WindowCovering', + [clusters.thermostat]: 'Thermostat', + [clusters.colorControl]: 'ColorControl', + [clusters.illuminanceMeasurement]: 'IlluminanceMeasurement', + [clusters.temperatureMeasurement]: 'TemperatureMeasurement', + [clusters.pressureMeasurement]: 'PressureMeasurement', + [clusters.flowMeasurement]: 'FlowMeasurement', + [clusters.relativeHumidityMeasurement]: 'RelativeHumidityMeasurement', + [clusters.occupancySensing]: 'OccupancySensing', +}; + +const attributeNames: Record> = { + [clusters.descriptor]: { 0: 'DeviceTypeList', 1: 'ServerList', 2: 'ClientList', 3: 'PartsList' }, + [clusters.basicInformation]: { 1: 'VendorName', 2: 'VendorID', 3: 'ProductName', 4: 'ProductID', 5: 'NodeLabel', 8: 'HardwareVersionString', 10: 'SoftwareVersionString', 14: 'ProductLabel', 15: 'SerialNumber' }, + [clusters.bridgedDeviceBasicInformation]: { 1: 'VendorName', 3: 'ProductName', 5: 'NodeLabel', 8: 'HardwareVersionString', 10: 'SoftwareVersionString', 14: 'ProductLabel', 15: 'SerialNumber', 17: 'Reachable' }, + [clusters.onOff]: { 0: 'OnOff' }, + [clusters.levelControl]: { 0: 'CurrentLevel', 2: 'MinLevel', 3: 'MaxLevel' }, + [clusters.colorControl]: { 0: 'CurrentHue', 1: 'CurrentSaturation', 3: 'CurrentX', 4: 'CurrentY', 7: 'ColorTemperatureMireds', 8: 'ColorMode' }, + [clusters.windowCovering]: { 0: 'Type', 10: 'OperationalStatus', 14: 'CurrentPositionLiftPercent100ths', 15: 'CurrentPositionTiltPercent100ths' }, + [clusters.doorLock]: { 0: 'LockState', 1: 'LockType', 2: 'ActuatorEnabled', 3: 'DoorState' }, + [clusters.thermostat]: { 0: 'LocalTemperature', 2: 'Occupancy', 17: 'OccupiedCoolingSetpoint', 18: 'OccupiedHeatingSetpoint', 27: 'ControlSequenceOfOperation', 28: 'SystemMode' }, + [clusters.powerSource]: { 12: 'BatPercentRemaining', 14: 'BatChargeLevel', 26: 'BatChargeState' }, + [clusters.booleanState]: { 0: 'StateValue' }, + [clusters.occupancySensing]: { 0: 'Occupancy' }, + [clusters.airQuality]: { 0: 'AirQuality' }, + [clusters.temperatureMeasurement]: { 0: 'MeasuredValue' }, + [clusters.relativeHumidityMeasurement]: { 0: 'MeasuredValue' }, + [clusters.illuminanceMeasurement]: { 0: 'MeasuredValue' }, + [clusters.pressureMeasurement]: { 0: 'MeasuredValue' }, + [clusters.flowMeasurement]: { 0: 'MeasuredValue' }, +}; + +const lightDeviceTypes = new Set([0x0100, 0x0101, 0x010c, 0x010d]); +const plugDeviceTypes = new Set([0x010a, 0x010b]); +const rootOnlyDeviceTypes = new Set([0x0013, 0x0016]); +const infrastructureClusters = new Set([clusters.descriptor, clusters.basicInformation, clusters.bridgedDeviceBasicInformation]); +const lockTimedRequestTimeoutMs = 10000; + +export class MatterMapper { + public static toSnapshot(configArg: IMatterConfig, connectedArg = false, eventsArg: IMatterServerEvent[] = []): IMatterSnapshot { + if (configArg.snapshot) { + return { + ...configArg.snapshot, + nodes: configArg.snapshot.nodes || [], + events: [...(configArg.snapshot.events || []), ...eventsArg], + connected: connectedArg || configArg.snapshot.connected, + url: configArg.snapshot.url || this.urlFromConfig(configArg), + }; + } + return { + serverInfo: configArg.serverInfo, + nodes: configArg.nodes || [], + events: [...(configArg.events || []), ...eventsArg], + connected: connectedArg, + url: this.urlFromConfig(configArg), + }; + } + + public static toDevices(snapshotArg: IMatterSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const entities = this.toEntities(snapshotArg); + const entitiesByDevice = new Map(); + for (const entity of entities) { + const list = entitiesByDevice.get(entity.deviceId) || []; + list.push(entity); + entitiesByDevice.set(entity.deviceId, list); + } + + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: this.controllerDeviceId(snapshotArg), + integrationDomain: 'matter', + name: 'Matter Server', + protocol: 'matter', + manufacturer: 'Matter', + model: 'Matter Server', + online: snapshotArg.connected || Boolean(snapshotArg.nodes.length), + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'node_count', capability: 'sensor', name: 'Node count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt }, + { featureId: 'node_count', value: snapshotArg.nodes.length, updatedAt }, + ], + metadata: { + url: snapshotArg.url, + schemaVersion: snapshotArg.serverInfo?.schema_version, + sdkVersion: snapshotArg.serverInfo?.sdk_version, + fabricId: this.safeStateValue(snapshotArg.serverInfo?.fabric_id), + compressedFabricId: this.safeStateValue(snapshotArg.serverInfo?.compressed_fabric_id), + }, + }]; + + for (const node of snapshotArg.nodes) { + let addedNodeDevice = false; + for (const endpoint of this.nodeEndpoints(node)) { + const endpointEntities = entitiesByDevice.get(this.endpointDeviceId(node, endpoint.endpointId)) || []; + if (!endpointEntities.length && !this.isUsefulEndpoint(endpoint)) { + continue; + } + devices.push(this.deviceForEndpoint(node, endpoint, endpointEntities, updatedAt)); + addedNodeDevice = true; + } + if (!addedNodeDevice) { + devices.push(this.fallbackDeviceForNode(node, updatedAt)); + } + } + return devices; + } + + public static toEntities(snapshotArg: IMatterSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const node of snapshotArg.nodes) { + for (const endpoint of this.nodeEndpoints(node)) { + entities.push(...this.entitiesForEndpoint(node, endpoint)); + } + } + return entities; + } + + public static toEndpoints(snapshotArg: IMatterSnapshot): IMatterEndpoint[] { + return snapshotArg.nodes.flatMap((nodeArg) => this.nodeEndpoints(nodeArg)); + } + + public static commandForService(snapshotArg: IMatterSnapshot, requestArg: IServiceCallRequest): IMatterServerCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target) { + return undefined; + } + + const platform = String(target.platform); + if (requestArg.service === 'turn_on' && (platform === 'light' || platform === 'switch')) { + return this.deviceCommand(target, clusters.onOff, 'On'); + } + if (requestArg.service === 'turn_off' && (platform === 'light' || platform === 'switch')) { + return this.deviceCommand(target, clusters.onOff, 'Off'); + } + if (requestArg.service === 'open_cover' && platform === 'cover') { + return this.deviceCommand(target, clusters.windowCovering, 'UpOrOpen'); + } + if (requestArg.service === 'close_cover' && platform === 'cover') { + return this.deviceCommand(target, clusters.windowCovering, 'DownOrClose'); + } + if (requestArg.service === 'set_position' && platform === 'cover') { + const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.value); + if (position === undefined) { + return undefined; + } + return this.deviceCommand(target, clusters.windowCovering, 'GoToLiftPercentage', { + liftPercent100thsValue: (100 - this.clamp(position, 0, 100)) * 100, + }); + } + if (requestArg.service === 'lock' && platform === 'lock') { + return this.lockCommand(target, 'LockDoor', requestArg.data?.code); + } + if (requestArg.service === 'unlock' && platform === 'lock') { + return this.lockCommand(target, 'UnlockDoor', requestArg.data?.code); + } + if (requestArg.service === 'set_value') { + return this.setValueCommand(target, requestArg.data || {}); + } + return undefined; + } + + public static nodeEndpoints(nodeArg: IMatterNode): IMatterEndpoint[] { + const attributesByEndpoint = new Map(); + for (const [path, value] of Object.entries(nodeArg.attributes || {})) { + const parsed = this.parseAttributePath(path); + if (!parsed) { + continue; + } + const attribute: IMatterAttribute = { + path, + endpointId: parsed.endpointId, + clusterId: parsed.clusterId, + attributeId: parsed.attributeId, + clusterName: this.clusterName(parsed.clusterId), + attributeName: this.attributeName(parsed.clusterId, parsed.attributeId), + value, + readable: true, + writable: this.attributeWritable(parsed.clusterId, parsed.attributeId), + }; + const list = attributesByEndpoint.get(parsed.endpointId) || []; + list.push(attribute); + attributesByEndpoint.set(parsed.endpointId, list); + } + + const rootAttributes = attributesByEndpoint.get(0) || []; + return [...attributesByEndpoint.entries()] + .sort(([left], [right]) => left - right) + .map(([endpointId, attributes]) => { + const serverList = this.numberList(this.attributeValue(attributes, clusters.descriptor, 1)); + const clusterIds = new Set(serverList); + for (const attribute of attributes) { + clusterIds.add(attribute.clusterId); + } + const endpoint: IMatterEndpoint = { + nodeId: nodeArg.node_id, + endpointId, + attributes, + deviceTypes: this.deviceTypesFromValue(this.attributeValue(attributes, clusters.descriptor, 0)), + clusters: [], + deviceInfo: this.deviceInfo(rootAttributes, attributes), + }; + endpoint.clusters = [...clusterIds].sort((left, right) => left - right).map((clusterId) => ({ + id: clusterId, + name: this.clusterName(clusterId), + attributes: attributes.filter((attributeArg) => attributeArg.clusterId === clusterId), + } satisfies IMatterCluster)); + return endpoint; + }); + } + + private static entitiesForEndpoint(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const hasOnOff = this.hasCluster(endpointArg, clusters.onOff) && this.hasAttribute(endpointArg, clusters.onOff, 0); + if (hasOnOff && this.isLightEndpoint(endpointArg)) { + entities.push(this.lightEntity(nodeArg, endpointArg)); + } else if (hasOnOff) { + entities.push(this.switchEntity(nodeArg, endpointArg)); + } + if (this.hasCluster(endpointArg, clusters.windowCovering)) { + entities.push(this.coverEntity(nodeArg, endpointArg)); + } + if (this.hasCluster(endpointArg, clusters.doorLock) && this.hasAttribute(endpointArg, clusters.doorLock, 0)) { + entities.push(this.lockEntity(nodeArg, endpointArg)); + } + if (this.hasCluster(endpointArg, clusters.thermostat)) { + entities.push(this.climateEntity(nodeArg, endpointArg)); + } + entities.push(...this.binarySensorEntities(nodeArg, endpointArg)); + entities.push(...this.sensorEntities(nodeArg, endpointArg)); + return entities; + } + + private static lightEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity { + const onOff = this.attributeValue(endpointArg.attributes, clusters.onOff, 0); + const currentLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 0)); + const minLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 2)) || 1; + const maxLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 3)) || 254; + const brightness = currentLevel === undefined ? undefined : Math.round(this.renormalize(currentLevel, [minLevel, maxLevel], [1, 255])); + return this.entity(nodeArg, endpointArg, 'light', 'light', 'Light', onOff === true ? 'on' : onOff === false ? 'off' : 'unknown', clusters.onOff, 0, { + brightness, + colorMode: this.attributeValue(endpointArg.attributes, clusters.colorControl, 8), + colorTemperatureMireds: this.attributeValue(endpointArg.attributes, clusters.colorControl, 7), + currentHue: this.attributeValue(endpointArg.attributes, clusters.colorControl, 0), + currentSaturation: this.attributeValue(endpointArg.attributes, clusters.colorControl, 1), + writable: true, + commands: ['On', 'Off', 'Toggle', 'MoveToLevelWithOnOff'], + }, true); + } + + private static switchEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity { + const onOff = this.attributeValue(endpointArg.attributes, clusters.onOff, 0); + return this.entity(nodeArg, endpointArg, 'switch', 'switch', 'Switch', onOff === true ? 'on' : onOff === false ? 'off' : 'unknown', clusters.onOff, 0, { + writable: true, + commands: ['On', 'Off', 'Toggle'], + }, true); + } + + private static coverEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity { + const operationalStatus = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 10)) || 0; + const rawPosition = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 14)); + const rawTilt = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 15)); + const position = rawPosition === undefined ? undefined : 100 - Math.floor(rawPosition / 100); + const tiltPosition = rawTilt === undefined ? undefined : 100 - Math.floor(rawTilt / 100); + let state = 'idle'; + if ((operationalStatus & 0b11) === 0b01) { + state = 'opening'; + } else if ((operationalStatus & 0b11) === 0b10) { + state = 'closing'; + } else if (position !== undefined) { + state = position <= 0 ? 'closed' : 'open'; + } + return this.entity(nodeArg, endpointArg, 'cover', 'cover', 'Cover', state, clusters.windowCovering, 10, { + position, + tiltPosition, + coverType: this.attributeValue(endpointArg.attributes, clusters.windowCovering, 0), + writable: true, + commands: ['UpOrOpen', 'DownOrClose', 'StopMotion', 'GoToLiftPercentage', 'GoToTiltPercentage'], + }, true); + } + + private static lockEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity { + const lockState = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.doorLock, 0)); + const state = lockState === 1 ? 'locked' : lockState === 2 ? 'unlocked' : lockState === 3 ? 'open' : 'unknown'; + return this.entity(nodeArg, endpointArg, 'lock', 'lock', 'Lock', state, clusters.doorLock, 0, { + lockState, + lockType: this.attributeValue(endpointArg.attributes, clusters.doorLock, 1), + actuatorEnabled: this.attributeValue(endpointArg.attributes, clusters.doorLock, 2), + writable: true, + commands: ['LockDoor', 'UnlockDoor'], + }, true); + } + + private static climateEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity { + const mode = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 28)); + const heatingPath = this.attributePath(endpointArg.endpointId, clusters.thermostat, 18); + const coolingPath = this.attributePath(endpointArg.endpointId, clusters.thermostat, 17); + return this.entity(nodeArg, endpointArg, 'climate', 'climate', 'Climate', this.thermostatMode(mode), clusters.thermostat, 0, { + currentTemperature: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 0)), + occupiedCoolingSetpoint: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 17)), + occupiedHeatingSetpoint: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 18)), + systemMode: mode, + writable: this.hasAttribute(endpointArg, clusters.thermostat, 17) || this.hasAttribute(endpointArg, clusters.thermostat, 18), + writableAttributePath: this.hasAttribute(endpointArg, clusters.thermostat, 18) ? heatingPath : coolingPath, + }, this.hasAttribute(endpointArg, clusters.thermostat, 17) || this.hasAttribute(endpointArg, clusters.thermostat, 18)); + } + + private static binarySensorEntities(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + if (this.hasAttribute(endpointArg, clusters.occupancySensing, 0)) { + const value = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.occupancySensing, 0)); + entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', 'occupancy', 'Occupancy', value === undefined ? undefined : (value & 1) === 1, clusters.occupancySensing, 0, { deviceClass: 'occupancy' })); + } + if (this.hasAttribute(endpointArg, clusters.booleanState, 0)) { + const value = this.attributeValue(endpointArg.attributes, clusters.booleanState, 0); + const sensorType = this.booleanSensorType(endpointArg); + entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', sensorType.key, sensorType.name, sensorType.inverted ? !Boolean(value) : Boolean(value), clusters.booleanState, 0, { deviceClass: sensorType.deviceClass })); + } + if (this.hasAttribute(endpointArg, clusters.doorLock, 3)) { + const doorState = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.doorLock, 3)); + const isOpen = doorState === undefined ? undefined : [0, 2, 3, 5].includes(doorState); + entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', 'door', 'Door', isOpen, clusters.doorLock, 3, { deviceClass: 'door' })); + } + return entities; + } + + private static sensorEntities(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] { + const definitions: Array<{ clusterId: number; attributeId: number; key: string; name: string; unit?: string; deviceClass?: string; transform?: (valueArg: unknown) => unknown }> = [ + { clusterId: clusters.temperatureMeasurement, attributeId: 0, key: 'temperature', name: 'Temperature', unit: '°C', deviceClass: 'temperature', transform: (valueArg) => this.temperatureValue(valueArg) }, + { clusterId: clusters.relativeHumidityMeasurement, attributeId: 0, key: 'humidity', name: 'Humidity', unit: '%', deviceClass: 'humidity', transform: (valueArg) => this.scaledNumber(valueArg, 100) }, + { clusterId: clusters.illuminanceMeasurement, attributeId: 0, key: 'illuminance', name: 'Illuminance', unit: 'lx', deviceClass: 'illuminance', transform: (valueArg) => this.illuminanceValue(valueArg) }, + { clusterId: clusters.pressureMeasurement, attributeId: 0, key: 'pressure', name: 'Pressure', deviceClass: 'pressure' }, + { clusterId: clusters.flowMeasurement, attributeId: 0, key: 'flow', name: 'Flow' }, + { clusterId: clusters.powerSource, attributeId: 12, key: 'battery', name: 'Battery', unit: '%', deviceClass: 'battery', transform: (valueArg) => this.scaledNumber(valueArg, 2) }, + { clusterId: clusters.airQuality, attributeId: 0, key: 'air_quality', name: 'Air quality', transform: (valueArg) => this.airQualityValue(valueArg) }, + ]; + const entities: IIntegrationEntity[] = []; + for (const definition of definitions) { + if (!this.hasAttribute(endpointArg, definition.clusterId, definition.attributeId)) { + continue; + } + const rawValue = this.attributeValue(endpointArg.attributes, definition.clusterId, definition.attributeId); + entities.push(this.entity(nodeArg, endpointArg, 'sensor', definition.key, definition.name, definition.transform ? definition.transform(rawValue) : rawValue, definition.clusterId, definition.attributeId, { + unit: definition.unit, + deviceClass: definition.deviceClass, + })); + } + return entities; + } + + private static entity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint, platformArg: TMatterEntityPlatform, keyArg: string, nameArg: string, stateArg: unknown, clusterIdArg: number, attributeIdArg: number, attributesArg: Record = {}, writableArg = false): IIntegrationEntity { + const deviceName = this.endpointName(nodeArg, endpointArg); + const featureId = this.slug(`${keyArg}_${clusterIdArg}_${attributeIdArg}`); + return { + id: `${platformArg}.${this.slug(`${deviceName} ${nameArg}`)}`, + uniqueId: `matter_${this.slug(`${String(nodeArg.node_id)}_${endpointArg.endpointId}_${keyArg}_${clusterIdArg}_${attributeIdArg}`)}`, + integrationDomain: 'matter', + deviceId: this.endpointDeviceId(nodeArg, endpointArg.endpointId), + platform: platformArg as TEntityPlatform, + name: nameArg, + state: stateArg ?? 'unknown', + attributes: { + ...attributesArg, + nodeId: nodeArg.node_id, + endpointId: endpointArg.endpointId, + clusterId: clusterIdArg, + attributeId: attributeIdArg, + attributePath: this.attributePath(endpointArg.endpointId, clusterIdArg, attributeIdArg), + clusterName: this.clusterName(clusterIdArg), + attributeName: this.attributeName(clusterIdArg, attributeIdArg), + featureId, + readable: true, + writable: writableArg, + }, + available: nodeArg.available !== false && this.reachable(endpointArg), + }; + } + + private static deviceForEndpoint(nodeArg: IMatterNode, endpointArg: IMatterEndpoint, entitiesArg: IIntegrationEntity[], updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }, + ...entitiesArg.map((entityArg) => this.featureForEntity(entityArg)), + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'availability', value: nodeArg.available !== false && this.reachable(endpointArg) ? 'online' : 'offline', updatedAt: updatedAtArg }, + ...entitiesArg.map((entityArg) => ({ featureId: this.stringValue(entityArg.attributes?.featureId) || this.slug(entityArg.uniqueId), value: this.safeStateValue(entityArg.state), updatedAt: updatedAtArg })), + ]; + return { + id: this.endpointDeviceId(nodeArg, endpointArg.endpointId), + integrationDomain: 'matter', + name: this.endpointName(nodeArg, endpointArg), + protocol: 'matter', + manufacturer: endpointArg.deviceInfo.vendorName, + model: endpointArg.deviceInfo.productLabel || endpointArg.deviceInfo.productName || endpointArg.deviceTypes[0]?.name, + online: nodeArg.available !== false && this.reachable(endpointArg), + features, + state, + metadata: { + nodeId: this.safeStateValue(nodeArg.node_id), + endpointId: endpointArg.endpointId, + deviceTypes: endpointArg.deviceTypes.map((typeArg) => ({ id: typeArg.id, name: typeArg.name, revision: typeArg.revision })), + vendorId: endpointArg.deviceInfo.vendorId, + productId: endpointArg.deviceInfo.productId, + serialNumber: endpointArg.deviceInfo.serialNumber, + hardwareVersion: endpointArg.deviceInfo.hardwareVersionString, + softwareVersion: endpointArg.deviceInfo.softwareVersionString, + matterVersion: nodeArg.matter_version, + }, + }; + } + + private static fallbackDeviceForNode(nodeArg: IMatterNode, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const endpoint = this.nodeEndpoints(nodeArg)[0]; + return { + id: `matter.node.${this.slug(String(nodeArg.node_id))}`, + integrationDomain: 'matter', + name: endpoint ? this.endpointName(nodeArg, endpoint) : `Matter node ${String(nodeArg.node_id)}`, + protocol: 'matter', + manufacturer: endpoint?.deviceInfo.vendorName, + model: endpoint?.deviceInfo.productName, + online: nodeArg.available !== false, + features: [{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }], + state: [{ featureId: 'availability', value: nodeArg.available !== false ? 'online' : 'offline', updatedAt: updatedAtArg }], + metadata: { nodeId: this.safeStateValue(nodeArg.node_id), matterVersion: nodeArg.matter_version }, + }; + } + + private static featureForEntity(entityArg: IIntegrationEntity): plugins.shxInterfaces.data.IDeviceFeature { + return { + id: this.stringValue(entityArg.attributes?.featureId) || this.slug(entityArg.uniqueId), + capability: this.capabilityForPlatform(String(entityArg.platform)), + name: entityArg.name, + readable: entityArg.attributes?.readable !== false, + writable: entityArg.attributes?.writable === true, + unit: this.stringValue(entityArg.attributes?.unit), + }; + } + + private static capabilityForPlatform(platformArg: string): plugins.shxInterfaces.data.TDeviceCapability { + if (platformArg === 'light') { + return 'light'; + } + if (platformArg === 'cover') { + return 'cover'; + } + if (platformArg === 'climate') { + return 'climate'; + } + if (platformArg === 'fan') { + return 'fan'; + } + return platformArg === 'lock' || platformArg === 'switch' ? 'switch' : 'sensor'; + } + + private static findTargetEntity(snapshotArg: IMatterSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.attributes?.writable === true) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId); + } + return entities.find((entityArg) => entityArg.attributes?.writable === true); + } + + private static setValueCommand(entityArg: IIntegrationEntity, dataArg: Record): IMatterServerCommand | undefined { + const platform = String(entityArg.platform); + const value = dataArg.value ?? dataArg.brightness ?? dataArg.temperature; + if ((platform === 'light' || platform === 'switch') && typeof value === 'boolean') { + return this.deviceCommand(entityArg, clusters.onOff, value ? 'On' : 'Off'); + } + if (platform === 'light') { + const brightness = this.numberValue(dataArg.brightness ?? dataArg.value); + if (brightness !== undefined) { + return this.deviceCommand(entityArg, clusters.levelControl, 'MoveToLevelWithOnOff', { + level: this.clamp(Math.round(this.renormalize(brightness, [0, 255], [1, 254])), 1, 254), + transitionTime: this.numberValue(dataArg.transition) ? Math.round((this.numberValue(dataArg.transition) || 0) * 10) : 0, + }); + } + } + if (platform === 'lock' && typeof value === 'boolean') { + return this.lockCommand(entityArg, value ? 'LockDoor' : 'UnlockDoor', dataArg.code); + } + const nodeId = this.nodeIdFromEntity(entityArg); + const attributePath = this.stringValue(dataArg.attribute_path) + || this.stringValue(dataArg.attributePath) + || this.stringValue(entityArg.attributes?.writableAttributePath) + || this.stringValue(entityArg.attributes?.attributePath); + if (nodeId === undefined || !attributePath || value === undefined) { + return undefined; + } + const writeValue = platform === 'climate' && typeof value === 'number' ? Math.round(value * 100) : value; + return { + command: 'write_attribute', + requireSchema: 4, + args: { + node_id: nodeId, + attribute_path: attributePath, + value: writeValue, + }, + }; + } + + private static deviceCommand(entityArg: IIntegrationEntity, clusterIdArg: number, commandNameArg: string, payloadArg: Record = {}): IMatterServerCommand | undefined { + const nodeId = this.nodeIdFromEntity(entityArg); + const endpointId = this.numberValue(entityArg.attributes?.endpointId); + if (nodeId === undefined || endpointId === undefined) { + return undefined; + } + return { + command: 'device_command', + args: { + node_id: nodeId, + endpoint_id: endpointId, + cluster_id: clusterIdArg, + command_name: commandNameArg, + payload: payloadArg, + response_type: null, + }, + }; + } + + private static lockCommand(entityArg: IIntegrationEntity, commandNameArg: string, codeArg: unknown): IMatterServerCommand | undefined { + const payload: Record = {}; + if (typeof codeArg === 'string' && codeArg) { + payload.PINCode = codeArg; + } + const command = this.deviceCommand(entityArg, clusters.doorLock, commandNameArg, payload); + if (command?.args) { + command.args.timed_request_timeout_ms = lockTimedRequestTimeoutMs; + } + return command; + } + + private static deviceInfo(rootAttributesArg: IMatterAttribute[], attributesArg: IMatterAttribute[]): IMatterDeviceInfo { + const endpointInfoCluster = this.hasAttributeList(attributesArg, clusters.bridgedDeviceBasicInformation) ? clusters.bridgedDeviceBasicInformation : clusters.basicInformation; + return { + vendorName: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 1)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 1)), + vendorId: this.numberValue(this.attributeValue(attributesArg, endpointInfoCluster, 2)) || this.numberValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 2)), + productName: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 3)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 3)), + productId: this.numberValue(this.attributeValue(attributesArg, endpointInfoCluster, 4)) || this.numberValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 4)), + nodeLabel: this.cleanName(this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 5))) || this.cleanName(this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 5))), + hardwareVersionString: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 8)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 8)), + softwareVersionString: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 10)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 10)), + productLabel: this.cleanName(this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 14))) || this.cleanName(this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 14))), + serialNumber: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 15)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 15)), + }; + } + + private static deviceTypesFromValue(valueArg: unknown): IMatterDeviceType[] { + if (!Array.isArray(valueArg)) { + return []; + } + return valueArg.map((itemArg) => { + if (typeof itemArg === 'number') { + return { id: itemArg, name: deviceTypes[itemArg] || `Device type ${itemArg}`, raw: itemArg }; + } + const item = this.asRecord(itemArg); + const id = this.numberValue(item.deviceType ?? item.device_type ?? item.deviceTypeId ?? item.id) || 0; + return { + id, + name: deviceTypes[id] || `Device type ${id}`, + revision: this.numberValue(item.revision), + raw: itemArg, + }; + }).filter((typeArg) => typeArg.id > 0); + } + + private static booleanSensorType(endpointArg: IMatterEndpoint): { key: string; name: string; deviceClass: string; inverted?: boolean } { + if (this.hasDeviceType(endpointArg, [0x0015])) { + return { key: 'contact', name: 'Contact', deviceClass: 'door', inverted: true }; + } + if (this.hasDeviceType(endpointArg, [0x0043])) { + return { key: 'water_leak', name: 'Water leak', deviceClass: 'moisture' }; + } + if (this.hasDeviceType(endpointArg, [0x0041])) { + return { key: 'freeze', name: 'Freeze', deviceClass: 'cold' }; + } + if (this.hasDeviceType(endpointArg, [0x0044])) { + return { key: 'rain', name: 'Rain', deviceClass: 'moisture' }; + } + return { key: 'state', name: 'State', deviceClass: 'problem' }; + } + + private static isLightEndpoint(endpointArg: IMatterEndpoint): boolean { + if (endpointArg.deviceTypes.some((typeArg) => lightDeviceTypes.has(typeArg.id))) { + return true; + } + if (endpointArg.deviceTypes.some((typeArg) => plugDeviceTypes.has(typeArg.id))) { + return false; + } + const name = `${endpointArg.deviceInfo.nodeLabel || ''} ${endpointArg.deviceInfo.productName || ''} ${endpointArg.deviceTypes.map((typeArg) => typeArg.name).join(' ')}`.toLowerCase(); + return name.includes('light') && (this.hasCluster(endpointArg, clusters.levelControl) || this.hasCluster(endpointArg, clusters.colorControl)); + } + + private static isUsefulEndpoint(endpointArg: IMatterEndpoint): boolean { + if (endpointArg.endpointId === 0) { + return false; + } + return endpointArg.deviceTypes.some((typeArg) => !rootOnlyDeviceTypes.has(typeArg.id)) + || endpointArg.clusters.some((clusterArg) => !infrastructureClusters.has(clusterArg.id)); + } + + private static reachable(endpointArg: IMatterEndpoint): boolean { + const value = this.attributeValue(endpointArg.attributes, clusters.bridgedDeviceBasicInformation, 17); + return typeof value === 'boolean' ? value : true; + } + + private static endpointName(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): string { + return endpointArg.deviceInfo.nodeLabel + || endpointArg.deviceInfo.productLabel + || endpointArg.deviceInfo.productName + || endpointArg.deviceTypes.find((typeArg) => !rootOnlyDeviceTypes.has(typeArg.id))?.name + || `Matter node ${String(nodeArg.node_id)}${endpointArg.endpointId ? ` endpoint ${endpointArg.endpointId}` : ''}`; + } + + private static endpointDeviceId(nodeArg: IMatterNode, endpointIdArg: number): string { + return `matter.node.${this.slug(String(nodeArg.node_id))}.endpoint.${endpointIdArg}`; + } + + private static controllerDeviceId(snapshotArg: IMatterSnapshot): string { + return `matter.server.${this.slug(String(snapshotArg.serverInfo?.compressed_fabric_id || snapshotArg.url || 'local'))}`; + } + + private static nodeIdFromEntity(entityArg: IIntegrationEntity): TMatterNodeId | undefined { + const value = entityArg.attributes?.nodeId; + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value; + } + return undefined; + } + + private static parseAttributePath(pathArg: string): { endpointId: number; clusterId: number; attributeId: number } | undefined { + const parts = pathArg.split('/').map((partArg) => Number(partArg)); + if (parts.length !== 3 || parts.some((partArg) => !Number.isFinite(partArg))) { + return undefined; + } + return { endpointId: parts[0], clusterId: parts[1], attributeId: parts[2] }; + } + + private static attributeValue(attributesArg: IMatterAttribute[], clusterIdArg: number, attributeIdArg: number): unknown { + return attributesArg.find((attributeArg) => attributeArg.clusterId === clusterIdArg && attributeArg.attributeId === attributeIdArg)?.value; + } + + private static hasAttribute(endpointArg: IMatterEndpoint, clusterIdArg: number, attributeIdArg: number): boolean { + return endpointArg.attributes.some((attributeArg) => attributeArg.clusterId === clusterIdArg && attributeArg.attributeId === attributeIdArg); + } + + private static hasAttributeList(attributesArg: IMatterAttribute[], clusterIdArg: number): boolean { + return attributesArg.some((attributeArg) => attributeArg.clusterId === clusterIdArg); + } + + private static hasCluster(endpointArg: IMatterEndpoint, clusterIdArg: number): boolean { + return endpointArg.clusters.some((clusterArg) => clusterArg.id === clusterIdArg); + } + + private static hasDeviceType(endpointArg: IMatterEndpoint, typeIdsArg: number[]): boolean { + return endpointArg.deviceTypes.some((typeArg) => typeIdsArg.includes(typeArg.id)); + } + + private static numberList(valueArg: unknown): number[] { + if (!Array.isArray(valueArg)) { + return []; + } + return valueArg.map((itemArg) => this.numberValue(itemArg)).filter((itemArg): itemArg is number => itemArg !== undefined); + } + + private static attributeWritable(clusterIdArg: number, attributeIdArg: number): boolean { + return clusterIdArg === clusters.thermostat && [17, 18].includes(attributeIdArg); + } + + private static attributePath(endpointIdArg: number, clusterIdArg: number, attributeIdArg: number): string { + return `${endpointIdArg}/${clusterIdArg}/${attributeIdArg}`; + } + + private static clusterName(clusterIdArg: number): string { + return clusterNames[clusterIdArg] || `Cluster ${clusterIdArg}`; + } + + private static attributeName(clusterIdArg: number, attributeIdArg: number): string { + return attributeNames[clusterIdArg]?.[attributeIdArg] + || (attributeIdArg === globalAttributes.acceptedCommandList ? 'AcceptedCommandList' : undefined) + || (attributeIdArg === globalAttributes.attributeList ? 'AttributeList' : undefined) + || (attributeIdArg === globalAttributes.featureMap ? 'FeatureMap' : undefined) + || (attributeIdArg === globalAttributes.clusterRevision ? 'ClusterRevision' : undefined) + || `Attribute ${attributeIdArg}`; + } + + private static thermostatMode(valueArg?: number): string { + return ({ 0: 'off', 1: 'auto', 3: 'cool', 4: 'heat', 5: 'emergency_heat', 7: 'fan_only', 8: 'dry', 9: 'sleep' } as Record)[valueArg ?? -1] || 'unknown'; + } + + private static airQualityValue(valueArg: unknown): unknown { + const value = this.numberValue(valueArg); + return ({ 0: 'unknown', 1: 'good', 2: 'fair', 3: 'moderate', 4: 'poor', 5: 'very_poor', 6: 'extremely_poor' } as Record)[value ?? -1] || valueArg; + } + + private static temperatureValue(valueArg: unknown): number | undefined { + return this.scaledNumber(valueArg, 100); + } + + private static illuminanceValue(valueArg: unknown): number | undefined { + const value = this.numberValue(valueArg); + if (value === undefined) { + return undefined; + } + return Math.round(Math.pow(10, (value - 1) / 10000) * 100) / 100; + } + + private static scaledNumber(valueArg: unknown, divisorArg: number): number | undefined { + const value = this.numberValue(valueArg); + return value === undefined ? undefined : Math.round((value / divisorArg) * 100) / 100; + } + + private static renormalize(valueArg: number, fromArg: [number, number], toArg: [number, number]): number { + const fromSpan = fromArg[1] - fromArg[0] || 1; + return ((valueArg - fromArg[0]) / fromSpan) * (toArg[1] - toArg[0]) + toArg[0]; + } + + private static clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); + } + + private static urlFromConfig(configArg: IMatterConfig): string | undefined { + if (configArg.url) { + return configArg.url; + } + if (configArg.host) { + return `ws://${configArg.host}:${configArg.port || 5580}/ws`; + } + return undefined; + } + + private static cleanName(valueArg?: string): string | undefined { + const cleaned = valueArg?.replace(/\x00/g, '').trim(); + return cleaned || undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'bigint' && valueArg <= BigInt(Number.MAX_SAFE_INTEGER) && valueArg >= BigInt(Number.MIN_SAFE_INTEGER)) { + return Number(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private static safeStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'bigint') { + return valueArg.toString(); + } + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static asRecord(valueArg: unknown): Record { + return this.isRecord(valueArg) ? valueArg : {}; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'matter'; + } +} diff --git a/ts/integrations/matter/matter.types.ts b/ts/integrations/matter/matter.types.ts index 04dfa83..f9dbdbf 100644 --- a/ts/integrations/matter/matter.types.ts +++ b/ts/integrations/matter/matter.types.ts @@ -1,4 +1,179 @@ -export interface IHomeAssistantMatterConfig { - // TODO: replace with the TypeScript-native config for matter. - [key: string]: unknown; +import type { TEntityPlatform } from '../../core/types.js'; + +export type TMatterNodeId = number | bigint | string; + +export type TMatterEntityPlatform = TEntityPlatform | 'lock'; + +export interface IMatterConfig { + url?: string; + host?: string; + port?: number; + connect?: boolean; + commandTimeoutMs?: number; + serverInfo?: IMatterServerInfo; + nodes?: IMatterNode[]; + events?: IMatterServerEvent[]; + snapshot?: IMatterSnapshot; } + +export interface IMatterServerInfo { + fabric_id: TMatterNodeId; + compressed_fabric_id: TMatterNodeId; + fabric_index?: number; + schema_version: number; + min_supported_schema_version: number; + sdk_version: string; + wifi_credentials_set: boolean; + thread_credentials_set: boolean; + bluetooth_enabled: boolean; +} + +export interface IMatterNode { + node_id: TMatterNodeId; + date_commissioned?: string; + last_interview?: string; + interview_version?: number; + available?: boolean; + is_bridge?: boolean; + attributes: Record; + attribute_subscriptions?: readonly unknown[]; + matter_version?: string; +} + +export interface IMatterEndpoint { + nodeId: TMatterNodeId; + endpointId: number; + deviceTypes: IMatterDeviceType[]; + clusters: IMatterCluster[]; + attributes: IMatterAttribute[]; + deviceInfo: IMatterDeviceInfo; +} + +export interface IMatterDeviceType { + id: number; + name: string; + revision?: number; + raw?: unknown; +} + +export interface IMatterCluster { + id: number; + name: string; + attributes: IMatterAttribute[]; +} + +export interface IMatterAttribute { + path: string; + endpointId: number; + clusterId: number; + attributeId: number; + clusterName: string; + attributeName: string; + value: unknown; + readable: boolean; + writable: boolean; +} + +export interface IMatterDeviceInfo { + nodeLabel?: string; + productLabel?: string; + productName?: string; + vendorName?: string; + vendorId?: number; + productId?: number; + serialNumber?: string; + hardwareVersionString?: string; + softwareVersionString?: string; +} + +export interface IMatterSnapshot { + serverInfo?: IMatterServerInfo; + nodes: IMatterNode[]; + events: IMatterServerEvent[]; + connected: boolean; + url?: string; +} + +export type TMatterServerEventName = + | 'node_added' + | 'node_updated' + | 'node_removed' + | 'node_event' + | 'attribute_updated' + | 'server_shutdown' + | 'endpoint_added' + | 'endpoint_removed' + | 'server_info_updated'; + +export interface IMatterServerEvent { + event: TMatterServerEventName | string; + data: TData; +} + +export interface IMatterNodeEvent { + node_id: TMatterNodeId; + endpoint_id: number; + cluster_id: number; + event_id: number; + event_number: TMatterNodeId; + priority: number; + timestamp: TMatterNodeId; + timestamp_type: number; + data: unknown | null; +} + +export interface IMatterCommandFrame { + message_id: string; + command: string; + args?: Record; +} + +export interface IMatterResultFrame { + message_id: string; + result: TResult; +} + +export interface IMatterErrorFrame { + message_id: string; + error_code: number; + details?: string; +} + +export interface IMatterServerCommand = Record> { + command: string; + args?: TArgs; + requireSchema?: number; + timeoutMs?: number; +} + +export interface IMatterServiceCommandArgs extends Record { + node_id?: TMatterNodeId; + endpoint_id?: number; + cluster_id?: number; + command_name?: string; + payload?: Record; + response_type?: unknown; + timed_request_timeout_ms?: number; + interaction_timeout_ms?: number; +} + +export interface IMatterMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: Record; + addresses?: string[]; +} + +export interface IMatterManualEntry { + url?: string; + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + metadata?: Record; +} + +export type TMatterDiscoveryRecord = IMatterMdnsRecord | IMatterManualEntry; diff --git a/ts/integrations/nanoleaf/.generated-by-smarthome-exchange b/ts/integrations/nanoleaf/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/nanoleaf/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/nanoleaf/index.ts b/ts/integrations/nanoleaf/index.ts index 9f40e32..fe1b7b7 100644 --- a/ts/integrations/nanoleaf/index.ts +++ b/ts/integrations/nanoleaf/index.ts @@ -1,2 +1,6 @@ +export * from './nanoleaf.classes.client.js'; +export * from './nanoleaf.classes.configflow.js'; export * from './nanoleaf.classes.integration.js'; +export * from './nanoleaf.discovery.js'; +export * from './nanoleaf.mapper.js'; export * from './nanoleaf.types.js'; diff --git a/ts/integrations/nanoleaf/nanoleaf.classes.client.ts b/ts/integrations/nanoleaf/nanoleaf.classes.client.ts new file mode 100644 index 0000000..60fdfa1 --- /dev/null +++ b/ts/integrations/nanoleaf/nanoleaf.classes.client.ts @@ -0,0 +1,280 @@ +import type { + INanoleafAuthTokenResult, + INanoleafConfig, + INanoleafControllerInfo, + INanoleafEffects, + INanoleafEffectsCommand, + INanoleafPanelLayout, + INanoleafRhythmInfo, + INanoleafSnapshot, + INanoleafState, + INanoleafStateCommand, +} from './nanoleaf.types.js'; + +const DEFAULT_NANOLEAF_PORT = 16021; + +export class NanoleafClient { + constructor(private readonly config: INanoleafConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.config.snapshot); + } + + if (this.canRequest()) { + const controllerInfo = await this.requestJson(this.authenticatedPath('')); + return this.normalizeSnapshot({ + controllerInfo, + state: controllerInfo.state ?? {}, + effects: controllerInfo.effects ?? {}, + panelLayout: controllerInfo.panelLayout, + rhythm: controllerInfo.rhythm, + }); + } + + return this.normalizeSnapshot({ + controllerInfo: this.config.controllerInfo ?? this.defaultControllerInfo(), + state: this.config.state ?? this.config.controllerInfo?.state ?? {}, + effects: this.config.effects ?? this.config.controllerInfo?.effects ?? {}, + panelLayout: this.config.panelLayout ?? this.config.controllerInfo?.panelLayout, + rhythm: this.config.rhythm ?? this.config.controllerInfo?.rhythm, + }); + } + + public async getControllerInfo(): Promise { + return (await this.getSnapshot()).controllerInfo; + } + + public async getState(): Promise { + if (this.config.snapshot || this.config.state || this.config.controllerInfo?.state || !this.canRequest()) { + return (await this.getSnapshot()).state; + } + return this.requestJson(this.authenticatedPath('/state')); + } + + public async getEffects(): Promise { + if (this.config.snapshot || this.config.effects || this.config.controllerInfo?.effects || !this.canRequest()) { + return (await this.getSnapshot()).effects; + } + return this.requestJson(this.authenticatedPath('/effects')); + } + + public async getPanelLayout(): Promise { + return (await this.getSnapshot()).panelLayout; + } + + public async getRhythm(): Promise { + return (await this.getSnapshot()).rhythm; + } + + public async turnOn(): Promise { + await this.setState({ on: { value: true } }); + } + + public async turnOff(transitionArg?: number): Promise { + if (transitionArg === undefined) { + await this.setState({ on: { value: false } }); + return; + } + await this.setState({ on: { value: false }, brightness: { value: 0, duration: transitionArg } }); + } + + public async setBrightness(percentArg: number, transitionArg?: number): Promise { + await this.setState({ + brightness: { + value: this.clamp(Math.round(percentArg), 0, 100), + duration: transitionArg, + }, + }); + } + + public async setHue(hueArg: number): Promise { + await this.setState({ hue: { value: this.clamp(Math.round(hueArg), 0, 360) } }); + } + + public async setSaturation(saturationArg: number): Promise { + await this.setState({ sat: { value: this.clamp(Math.round(saturationArg), 0, 100) } }); + } + + public async setColorTemperature(kelvinArg: number): Promise { + await this.setState({ ct: { value: Math.round(kelvinArg) } }); + } + + public async setState(stateArg: INanoleafStateCommand): Promise { + this.applyStatePatch(stateArg); + if (this.canRequest()) { + await this.requestJson(this.authenticatedPath('/state'), { + method: 'PUT', + body: stateArg, + }); + return; + } + this.assertFixtureMode(); + } + + public async setEffect(effectArg: string): Promise { + this.applyEffectsPatch({ select: effectArg }); + if (this.canRequest()) { + await this.requestJson(this.authenticatedPath('/effects'), { + method: 'PUT', + body: { select: effectArg }, + }); + return; + } + this.assertFixtureMode(); + } + + public async writeEffectsCommand(commandArg: INanoleafEffectsCommand): Promise { + if (this.canRequest()) { + await this.requestJson(this.authenticatedPath('/effects'), { + method: 'PUT', + body: { write: commandArg }, + }); + return; + } + this.assertFixtureMode(); + } + + public async identify(): Promise { + if (this.canRequest()) { + await this.requestJson(this.authenticatedPath('/identify'), { method: 'PUT' }); + return; + } + this.assertFixtureMode(); + } + + public async createAuthToken(): Promise { + return { + success: false, + error: 'Nanoleaf pairing/token generation is not implemented. Put the controller in pairing mode and provide an existing authToken.', + }; + } + + public async destroy(): Promise {} + + private normalizeSnapshot(snapshotArg: INanoleafSnapshot): INanoleafSnapshot { + const controllerInfo = snapshotArg.controllerInfo; + const state = snapshotArg.state ?? controllerInfo.state ?? {}; + const effects = snapshotArg.effects ?? controllerInfo.effects ?? {}; + const panelLayout = snapshotArg.panelLayout ?? controllerInfo.panelLayout; + const rhythm = snapshotArg.rhythm ?? controllerInfo.rhythm; + controllerInfo.state = state; + controllerInfo.effects = effects; + controllerInfo.panelLayout = panelLayout; + controllerInfo.rhythm = rhythm; + return { controllerInfo, state, effects, panelLayout, rhythm }; + } + + private applyStatePatch(stateArg: INanoleafStateCommand): void { + const state = this.ensureMutableState(); + if (stateArg.on) { + state.on = { ...state.on, ...stateArg.on }; + } + if (stateArg.brightness) { + state.brightness = { ...state.brightness, ...stateArg.brightness }; + } + if (stateArg.hue) { + state.hue = { ...state.hue, ...stateArg.hue }; + } + if (stateArg.sat) { + state.sat = { ...state.sat, ...stateArg.sat }; + } + if (stateArg.ct) { + state.ct = { ...state.ct, ...stateArg.ct }; + } + } + + private applyEffectsPatch(effectsArg: Partial): void { + const effects = this.ensureMutableEffects(); + Object.assign(effects, effectsArg); + } + + private ensureMutableState(): INanoleafState { + if (this.config.snapshot) { + this.config.snapshot.state ||= {}; + this.config.snapshot.controllerInfo.state = this.config.snapshot.state; + return this.config.snapshot.state; + } + this.config.state ||= this.config.controllerInfo?.state ?? {}; + if (this.config.controllerInfo) { + this.config.controllerInfo.state = this.config.state; + } + return this.config.state; + } + + private ensureMutableEffects(): INanoleafEffects { + if (this.config.snapshot) { + this.config.snapshot.effects ||= {}; + this.config.snapshot.controllerInfo.effects = this.config.snapshot.effects; + return this.config.snapshot.effects; + } + this.config.effects ||= this.config.controllerInfo?.effects ?? {}; + if (this.config.controllerInfo) { + this.config.controllerInfo.effects = this.config.effects; + } + return this.config.effects; + } + + private assertFixtureMode(): void { + if (!this.hasFixtureData()) { + throw new Error('Nanoleaf host and authToken are required when snapshot/manual config data is not provided.'); + } + } + + private hasFixtureData(): boolean { + return Boolean(this.config.snapshot || this.config.controllerInfo || this.config.state || this.config.effects || this.config.panelLayout || this.config.rhythm); + } + + private canRequest(): boolean { + return Boolean(this.config.host && this.authToken()); + } + + private authToken(): string | undefined { + return this.config.authToken || this.config.token; + } + + private authenticatedPath(pathArg: string): string { + const token = this.authToken(); + if (!token) { + throw new Error('Nanoleaf authToken is required for local HTTP requests.'); + } + return `/api/v1/${encodeURIComponent(token)}${pathArg}`; + } + + private async requestJson(pathArg: string, optionsArg: { method?: string; body?: unknown } = {}): Promise { + const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`, { + method: optionsArg.method ?? (optionsArg.body === undefined ? 'GET' : 'POST'), + headers: optionsArg.body === undefined ? undefined : { 'content-type': 'application/json' }, + body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Nanoleaf request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + if (!text) { + return undefined as TResult; + } + return JSON.parse(text) as TResult; + } + + private baseUrl(): string { + if (!this.config.host) { + throw new Error('Nanoleaf host is required for local HTTP requests.'); + } + const protocol = this.config.protocol ?? 'http'; + const port = this.config.port ?? DEFAULT_NANOLEAF_PORT; + return `${protocol}://${this.config.host}:${port}`; + } + + private defaultControllerInfo(): INanoleafControllerInfo { + return { + name: 'Nanoleaf', + manufacturer: 'Nanoleaf', + model: 'Nanoleaf Controller', + }; + } + + private clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); + } +} diff --git a/ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts b/ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts new file mode 100644 index 0000000..c8b3dc6 --- /dev/null +++ b/ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts @@ -0,0 +1,29 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { INanoleafConfig } from './nanoleaf.types.js'; + +const DEFAULT_NANOLEAF_PORT = 16021; + +export class NanoleafConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Nanoleaf', + description: 'Configure the local Nanoleaf HTTP endpoint with an existing auth token from the controller.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number', required: true }, + { name: 'authToken', label: 'Auth token', type: 'password', required: true }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'Nanoleaf configured', + config: { + host: String(valuesArg.host || candidateArg.host || ''), + port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || DEFAULT_NANOLEAF_PORT, + authToken: String(valuesArg.authToken || ''), + }, + }), + }; + } +} diff --git a/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts b/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts index d009d1e..249358c 100644 --- a/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts +++ b/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts @@ -1,30 +1,227 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { NanoleafClient } from './nanoleaf.classes.client.js'; +import { NanoleafConfigFlow } from './nanoleaf.classes.configflow.js'; +import { createNanoleafDiscoveryDescriptor } from './nanoleaf.discovery.js'; +import { NanoleafMapper } from './nanoleaf.mapper.js'; +import type { INanoleafConfig } from './nanoleaf.types.js'; -export class HomeAssistantNanoleafIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "nanoleaf", - displayName: "Nanoleaf", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/nanoleaf", - "upstreamDomain": "nanoleaf", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "aionanoleaf2==1.0.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@milanmeu", - "@joostlek", - "@loebi-ch", - "@JaspervRijbroek", - "@jonathanrobichaud4" - ] -}, - }); +export class NanoleafIntegration extends BaseIntegration { + public readonly domain = 'nanoleaf'; + public readonly displayName = 'Nanoleaf'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createNanoleafDiscoveryDescriptor(); + public readonly configFlow = new NanoleafConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/nanoleaf', + upstreamDomain: 'nanoleaf', + integrationType: 'device', + iotClass: 'local_push', + documentation: 'https://www.home-assistant.io/integrations/nanoleaf', + requirements: ['aionanoleaf2==1.0.2'], + codeowners: ['@milanmeu', '@joostlek', '@loebi-ch', '@JaspervRijbroek', '@jonathanrobichaud4'], + zeroconf: ['_nanoleafms._tcp.local.', '_nanoleafapi._tcp.local.'], + ssdp: ['Nanoleaf_aurora:light', 'nanoleaf:nl29', 'nanoleaf:nl42', 'nanoleaf:nl52', 'nanoleaf:nl69', 'inanoleaf:nl81'], + homekitModels: ['NL29', 'NL42', 'NL47', 'NL48', 'NL52', 'NL59', 'NL69', 'NL81'], + }; + + public async setup(configArg: INanoleafConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new NanoleafRuntime(new NanoleafClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantNanoleafIntegration extends NanoleafIntegration {} + +class NanoleafRuntime implements IIntegrationRuntime { + public domain = 'nanoleaf'; + + constructor(private readonly client: NanoleafClient) {} + + public async devices(): Promise { + return NanoleafMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return NanoleafMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'nanoleaf' && requestArg.service === 'create_auth_token') { + const result = await this.client.createAuthToken(); + return { success: result.success, error: result.error, data: result }; + } + + if (requestArg.domain === 'button') { + if (requestArg.service === 'press') { + await this.client.identify(); + return { success: true }; + } + return { success: false, error: `Unsupported Nanoleaf button service: ${requestArg.service}` }; + } + + if (requestArg.domain === 'select') { + return this.handleSelectEffect(requestArg); + } + + if (requestArg.domain === 'number') { + return this.handleNumberService(requestArg); + } + + if (requestArg.domain !== 'light') { + return { success: false, error: `Unsupported Nanoleaf service domain: ${requestArg.domain}` }; + } + + if (requestArg.service === 'turn_on') { + return this.handleTurnOn(requestArg); + } + if (requestArg.service === 'turn_off') { + await this.client.turnOff(this.numberValue(requestArg.data, 'transition')); + return { success: true }; + } + if (requestArg.service === 'set_value') { + return this.handleSetValue(requestArg); + } + if (requestArg.service === 'set_percentage' || requestArg.service === 'set_brightness') { + return this.handleSetBrightness(requestArg); + } + if (requestArg.service === 'select_effect') { + return this.handleSelectEffect(requestArg); + } + + return { success: false, error: `Unsupported Nanoleaf light service: ${requestArg.service}` }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async handleTurnOn(requestArg: IServiceCallRequest): Promise { + const effect = this.stringValue(requestArg.data, 'effect', 'option'); + if (effect) { + await this.client.setEffect(effect); + } + + const hsColor = requestArg.data?.hs_color; + if (Array.isArray(hsColor) && typeof hsColor[0] === 'number' && typeof hsColor[1] === 'number') { + await this.client.setHue(hsColor[0]); + await this.client.setSaturation(hsColor[1]); + } + + const colorTemperature = this.numberValue(requestArg.data, 'color_temp_kelvin', 'kelvin', 'ct'); + if (colorTemperature !== undefined) { + await this.client.setColorTemperature(colorTemperature); + } + + await this.client.turnOn(); + + const brightness = this.brightnessPercent(requestArg.data); + if (brightness !== undefined) { + await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition')); + } + + return { success: true }; + } + + private async handleNumberService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'set_value' && requestArg.service !== 'set_percentage' && requestArg.service !== 'set_brightness') { + return { success: false, error: `Unsupported Nanoleaf number service: ${requestArg.service}` }; + } + return requestArg.service === 'set_value' ? this.handleSetValue(requestArg) : this.handleSetBrightness(requestArg); + } + + private async handleSetValue(requestArg: IServiceCallRequest): Promise { + const value = this.numberValue(requestArg.data, 'value', 'brightness', 'percentage'); + if (value === undefined) { + return { success: false, error: 'Nanoleaf set_value requires data.value.' }; + } + + const target = `${requestArg.target.entityId ?? ''} ${this.stringValue(requestArg.data, 'attribute', 'feature') ?? ''}`.toLowerCase(); + if (target.includes('hue')) { + await this.client.setHue(value); + return { success: true }; + } + if (target.includes('saturation') || target.includes('sat')) { + await this.client.setSaturation(value); + return { success: true }; + } + if (target.includes('temperature') || target.includes('color_temp') || target.includes('ct')) { + await this.client.setColorTemperature(value); + return { success: true }; + } + + await this.client.setBrightness(this.brightnessPercent(requestArg.data) ?? value, this.numberValue(requestArg.data, 'transition')); + return { success: true }; + } + + private async handleSetBrightness(requestArg: IServiceCallRequest): Promise { + const brightness = this.brightnessPercent(requestArg.data); + if (brightness === undefined) { + return { success: false, error: 'Nanoleaf brightness service requires data.percentage, data.brightness, or data.value.' }; + } + await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition')); + return { success: true }; + } + + private async handleSelectEffect(requestArg: IServiceCallRequest): Promise { + const effect = this.stringValue(requestArg.data, 'effect', 'option', 'value'); + if (!effect) { + return { success: false, error: 'Nanoleaf select_effect requires data.effect or data.option.' }; + } + await this.client.setEffect(effect); + return { success: true }; + } + + private brightnessPercent(dataArg: Record | undefined): number | undefined { + const percentage = this.numberValue(dataArg, 'brightness_pct', 'percentage', 'percent', 'value'); + if (percentage !== undefined) { + return this.clamp(Math.round(percentage), 0, 100); + } + const brightness = this.numberValue(dataArg, 'brightness'); + if (brightness === undefined) { + return undefined; + } + return this.clamp(Math.round(brightness > 100 ? brightness / 2.55 : brightness), 0, 100); + } + + private numberValue(dataArg: Record | undefined, ...keysArg: string[]): number | undefined { + if (!dataArg) { + return undefined; + } + for (const key of keysArg) { + const value = dataArg[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) { + return Number(value); + } + } + return undefined; + } + + private stringValue(dataArg: Record | undefined, ...keysArg: string[]): string | undefined { + if (!dataArg) { + return undefined; + } + for (const key of keysArg) { + const value = dataArg[key]; + if (typeof value === 'string' && value) { + return value; + } + } + return undefined; + } + + private clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); } } diff --git a/ts/integrations/nanoleaf/nanoleaf.discovery.ts b/ts/integrations/nanoleaf/nanoleaf.discovery.ts new file mode 100644 index 0000000..9474267 --- /dev/null +++ b/ts/integrations/nanoleaf/nanoleaf.discovery.ts @@ -0,0 +1,208 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { INanoleafManualEntry, INanoleafMdnsRecord, INanoleafSsdpRecord } from './nanoleaf.types.js'; + +const DEFAULT_NANOLEAF_PORT = 16021; +const NANOLEAF_MDNS_TYPES = new Set(['_nanoleafapi._tcp.local.', '_nanoleafms._tcp.local.']); +const NANOLEAF_SSDP_TYPES = new Set([ + 'nanoleaf_aurora:light', + 'nanoleaf:nl29', + 'nanoleaf:nl42', + 'nanoleaf:nl52', + 'nanoleaf:nl69', + 'inanoleaf:nl81', +]); + +export class NanoleafMdnsMatcher implements IDiscoveryMatcher { + public id = 'nanoleaf-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Nanoleaf zeroconf records for _nanoleafapi._tcp and _nanoleafms._tcp.'; + + public async matches(recordArg: INanoleafMdnsRecord): Promise { + const type = recordArg.type?.toLowerCase() || ''; + const name = recordArg.name?.toLowerCase() || ''; + const matched = NANOLEAF_MDNS_TYPES.has(type) || name.includes('nanoleaf') && (type.includes('nanoleaf') || type === '_http._tcp.local.'); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'mDNS record is not a Nanoleaf advertisement.', + }; + } + + const deviceId = this.txtValue(recordArg.txt, 'id', 'deviceid', 'nl-deviceid') || recordArg.name; + return { + matched: true, + confidence: deviceId ? 'certain' : 'high', + reason: 'mDNS record matches Nanoleaf zeroconf metadata.', + normalizedDeviceId: deviceId, + candidate: { + source: 'mdns', + integrationDomain: 'nanoleaf', + id: deviceId, + host: recordArg.host, + port: recordArg.port || DEFAULT_NANOLEAF_PORT, + name: recordArg.name, + manufacturer: 'Nanoleaf', + model: this.txtValue(recordArg.txt, 'model', 'modelid', 'md'), + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + }, + }, + }; + } + + private txtValue(txtArg: Record | undefined, ...keysArg: string[]): string | undefined { + if (!txtArg) { + return undefined; + } + const wanted = new Set(keysArg.map((keyArg) => keyArg.toLowerCase())); + for (const [key, value] of Object.entries(txtArg)) { + if (value !== undefined && wanted.has(key.toLowerCase())) { + return value; + } + } + return undefined; + } +} + +export class NanoleafSsdpMatcher implements IDiscoveryMatcher { + public id = 'nanoleaf-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Nanoleaf SSDP responses by ST and nl-* headers.'; + + public async matches(recordArg: INanoleafSsdpRecord): Promise { + const st = (recordArg.st || this.header(recordArg.headers, 'st') || '').toLowerCase(); + const matched = NANOLEAF_SSDP_TYPES.has(st) || st.includes('nanoleaf') || Boolean(this.header(recordArg.headers, 'nl-deviceid')); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'SSDP response is not a Nanoleaf advertisement.', + }; + } + + const hostPort = this.parseHostPort(this.header(recordArg.headers, '_host') || recordArg.location); + const deviceId = this.header(recordArg.headers, 'nl-deviceid') || recordArg.usn; + return { + matched: true, + confidence: deviceId ? 'certain' : 'high', + reason: 'SSDP response matches Nanoleaf metadata.', + normalizedDeviceId: deviceId, + candidate: { + source: 'ssdp', + integrationDomain: 'nanoleaf', + id: deviceId, + host: hostPort.host, + port: hostPort.port || DEFAULT_NANOLEAF_PORT, + name: this.header(recordArg.headers, 'nl-devicename'), + manufacturer: 'Nanoleaf', + model: st.startsWith('nanoleaf:') || st.startsWith('inanoleaf:') ? st.split(':')[1]?.toUpperCase() : undefined, + metadata: { + st: recordArg.st, + usn: recordArg.usn, + location: recordArg.location, + headers: recordArg.headers, + }, + }, + }; + } + + private header(headersArg: Record | undefined, keyArg: string): string | undefined { + if (!headersArg) { + return undefined; + } + const wanted = keyArg.toLowerCase(); + for (const [key, value] of Object.entries(headersArg)) { + if (key.toLowerCase() === wanted) { + return value; + } + } + return undefined; + } + + private parseHostPort(valueArg: string | undefined): { host?: string; port?: number } { + if (!valueArg) { + return {}; + } + try { + const url = new URL(valueArg.includes('://') ? valueArg : `http://${valueArg}`); + return { + host: url.hostname, + port: url.port ? Number(url.port) : undefined, + }; + } catch { + const [host, port] = valueArg.split(':'); + return { host, port: port ? Number(port) : undefined }; + } + } +} + +export class NanoleafManualMatcher implements IDiscoveryMatcher { + public id = 'nanoleaf-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Nanoleaf setup entries by host or Nanoleaf metadata.'; + + public async matches(inputArg: INanoleafManualEntry): Promise { + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const model = inputArg.model?.toLowerCase() || ''; + const name = inputArg.name?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf') || inputArg.metadata?.nanoleaf); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'Manual entry does not contain Nanoleaf setup hints.', + }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Nanoleaf setup.', + normalizedDeviceId: inputArg.id, + candidate: { + source: 'manual', + integrationDomain: 'nanoleaf', + id: inputArg.id, + host: inputArg.host, + port: inputArg.port || DEFAULT_NANOLEAF_PORT, + name: inputArg.name, + manufacturer: 'Nanoleaf', + model: inputArg.model, + metadata: inputArg.metadata, + }, + }; + } +} + +export class NanoleafCandidateValidator implements IDiscoveryValidator { + public id = 'nanoleaf-candidate-validator'; + public description = 'Validate candidate metadata before starting Nanoleaf setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const name = candidateArg.name?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'nanoleaf' || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf'); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Nanoleaf metadata.' : 'Candidate is not Nanoleaf.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createNanoleafDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'nanoleaf', + displayName: 'Nanoleaf', + }) + .addMatcher(new NanoleafMdnsMatcher()) + .addMatcher(new NanoleafSsdpMatcher()) + .addMatcher(new NanoleafManualMatcher()) + .addValidator(new NanoleafCandidateValidator()); +}; diff --git a/ts/integrations/nanoleaf/nanoleaf.mapper.ts b/ts/integrations/nanoleaf/nanoleaf.mapper.ts new file mode 100644 index 0000000..a4017b8 --- /dev/null +++ b/ts/integrations/nanoleaf/nanoleaf.mapper.ts @@ -0,0 +1,274 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { + INanoleafEvent, + INanoleafPanelInfo, + INanoleafSnapshot, + INanoleafState, + INanoleafValue, +} from './nanoleaf.types.js'; + +const RESERVED_EFFECTS = new Set(['*Solid*', '*Static*', '*Dynamic*']); + +export class NanoleafMapper { + public static toDevices(snapshotArg: INanoleafSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = []; + const updatedAt = new Date().toISOString(); + const controllerDeviceId = this.controllerDeviceId(snapshotArg); + const controllerName = this.controllerName(snapshotArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }, + { id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }, + { id: 'hue', capability: 'light', name: 'Hue', readable: true, writable: true }, + { id: 'saturation', capability: 'light', name: 'Saturation', readable: true, writable: true, unit: '%' }, + { id: 'color_temperature', capability: 'light', name: 'Color temperature', readable: true, writable: true, unit: 'K' }, + { id: 'color_mode', capability: 'sensor', name: 'Color mode', readable: true, writable: false }, + { id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: 'online', updatedAt }, + { featureId: 'on', value: this.stateValue(snapshotArg.state.on) ?? null, updatedAt }, + { featureId: 'brightness', value: this.stateValue(snapshotArg.state.brightness) ?? null, updatedAt }, + { featureId: 'hue', value: this.stateValue(snapshotArg.state.hue) ?? null, updatedAt }, + { featureId: 'saturation', value: this.stateValue(snapshotArg.state.sat) ?? null, updatedAt }, + { featureId: 'color_temperature', value: this.stateValue(snapshotArg.state.ct) ?? null, updatedAt }, + { featureId: 'color_mode', value: this.colorMode(snapshotArg.state) ?? null, updatedAt }, + { featureId: 'effect', value: this.currentEffect(snapshotArg) ?? null, updatedAt }, + ]; + + if (snapshotArg.controllerInfo.firmwareVersion) { + features.push({ id: 'firmware', capability: 'sensor', name: 'Firmware', readable: true, writable: false }); + state.push({ featureId: 'firmware', value: snapshotArg.controllerInfo.firmwareVersion, updatedAt }); + } + + const panelCount = this.panelCount(snapshotArg); + if (panelCount !== undefined) { + features.push({ id: 'panel_count', capability: 'sensor', name: 'Panel count', readable: true, writable: false }); + state.push({ featureId: 'panel_count', value: panelCount, updatedAt }); + } + + const rhythmConnected = this.valueLike(snapshotArg.rhythm?.rhythmConnected); + if (rhythmConnected !== undefined) { + features.push({ id: 'rhythm_connected', capability: 'sensor', name: 'Rhythm connected', readable: true, writable: false }); + state.push({ featureId: 'rhythm_connected', value: rhythmConnected, updatedAt }); + } + + devices.push({ + id: controllerDeviceId, + integrationDomain: 'nanoleaf', + name: controllerName, + protocol: 'http', + manufacturer: snapshotArg.controllerInfo.manufacturer || 'Nanoleaf', + model: snapshotArg.controllerInfo.model || 'Nanoleaf Controller', + online: true, + features, + state, + metadata: { + serialNumber: snapshotArg.controllerInfo.serialNo, + hardwareVersion: snapshotArg.controllerInfo.hardwareVersion, + firmwareVersion: snapshotArg.controllerInfo.firmwareVersion, + effectsList: this.availableEffects(snapshotArg), + }, + }); + + for (const panel of this.panels(snapshotArg)) { + devices.push(this.panelDevice(snapshotArg, panel, updatedAt)); + } + + return devices; + } + + public static toEntities(snapshotArg: INanoleafSnapshot): IIntegrationEntity[] { + const deviceId = this.controllerDeviceId(snapshotArg); + const deviceSlug = this.slug(this.controllerName(snapshotArg)); + const entities: IIntegrationEntity[] = [ + { + id: `light.${deviceSlug}`, + uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_light`, + integrationDomain: 'nanoleaf', + deviceId, + platform: 'light', + name: this.controllerName(snapshotArg), + state: this.stateValue(snapshotArg.state.on) ? 'on' : 'off', + attributes: { + brightness: this.stateValue(snapshotArg.state.brightness), + hue: this.stateValue(snapshotArg.state.hue), + saturation: this.stateValue(snapshotArg.state.sat), + color_temp_kelvin: this.stateValue(snapshotArg.state.ct), + color_mode: this.colorMode(snapshotArg.state), + effect: this.currentEffect(snapshotArg), + effect_list: this.availableEffects(snapshotArg), + }, + available: true, + }, + { + id: `button.${deviceSlug}_identify`, + uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_identify`, + integrationDomain: 'nanoleaf', + deviceId, + platform: 'button', + name: `${this.controllerName(snapshotArg)} identify`, + state: null, + attributes: { deviceClass: 'identify' }, + available: true, + }, + ]; + + entities.push({ + id: `select.${deviceSlug}_effect`, + uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_effect`, + integrationDomain: 'nanoleaf', + deviceId, + platform: 'select', + name: `${this.controllerName(snapshotArg)} effect`, + state: this.currentEffect(snapshotArg) ?? null, + attributes: { options: this.availableEffects(snapshotArg) }, + available: true, + }); + + this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_color_mode`, `sensor.${deviceSlug}_color_mode`, `${this.controllerName(snapshotArg)} color mode`, this.colorMode(snapshotArg.state)); + this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_firmware`, `sensor.${deviceSlug}_firmware`, `${this.controllerName(snapshotArg)} firmware`, snapshotArg.controllerInfo.firmwareVersion); + this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_panel_count`, `sensor.${deviceSlug}_panel_count`, `${this.controllerName(snapshotArg)} panel count`, this.panelCount(snapshotArg)); + this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_rhythm_connected`, `sensor.${deviceSlug}_rhythm_connected`, `${this.controllerName(snapshotArg)} rhythm connected`, this.valueLike(snapshotArg.rhythm?.rhythmConnected)); + this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_rhythm_active`, `sensor.${deviceSlug}_rhythm_active`, `${this.controllerName(snapshotArg)} rhythm active`, this.valueLike(snapshotArg.rhythm?.rhythmActive)); + + for (const panel of this.panels(snapshotArg)) { + const panelDeviceId = this.panelDeviceId(snapshotArg, panel); + entities.push({ + id: `sensor.${deviceSlug}_panel_${panel.panelId}`, + uniqueId: `nanoleaf_${this.slug(deviceId)}_panel_${panel.panelId}`, + integrationDomain: 'nanoleaf', + deviceId: panelDeviceId, + platform: 'sensor', + name: `${this.controllerName(snapshotArg)} panel ${panel.panelId}`, + state: panel.panelId, + attributes: { + x: panel.x, + y: panel.y, + orientation: panel.o, + shapeType: panel.shapeType, + }, + available: true, + }); + } + + return entities; + } + + public static toIntegrationEvent(eventArg: INanoleafEvent): IIntegrationEvent { + return { + type: 'state_changed', + integrationDomain: 'nanoleaf', + data: eventArg, + timestamp: eventArg.timestamp ?? Date.now(), + }; + } + + private static panelDevice(snapshotArg: INanoleafSnapshot, panelArg: INanoleafPanelInfo, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'layout_x', capability: 'sensor', name: 'Layout X', readable: true, writable: false }, + { id: 'layout_y', capability: 'sensor', name: 'Layout Y', readable: true, writable: false }, + { id: 'orientation', capability: 'sensor', name: 'Orientation', readable: true, writable: false }, + { id: 'shape_type', capability: 'sensor', name: 'Shape type', readable: true, writable: false }, + ]; + return { + id: this.panelDeviceId(snapshotArg, panelArg), + integrationDomain: 'nanoleaf', + name: `${this.controllerName(snapshotArg)} Panel ${panelArg.panelId}`, + protocol: 'http', + manufacturer: 'Nanoleaf', + model: panelArg.shapeType === undefined ? 'Panel' : `Panel shape ${panelArg.shapeType}`, + online: true, + features, + state: [ + { featureId: 'layout_x', value: panelArg.x ?? null, updatedAt: updatedAtArg }, + { featureId: 'layout_y', value: panelArg.y ?? null, updatedAt: updatedAtArg }, + { featureId: 'orientation', value: panelArg.o ?? null, updatedAt: updatedAtArg }, + { featureId: 'shape_type', value: panelArg.shapeType ?? null, updatedAt: updatedAtArg }, + ], + metadata: { + panelId: panelArg.panelId, + controllerDeviceId: this.controllerDeviceId(snapshotArg), + }, + }; + } + + private static pushSensorEntity( + entitiesArg: IIntegrationEntity[], + deviceIdArg: string, + uniqueIdArg: string, + idArg: string, + nameArg: string, + valueArg: unknown + ): void { + if (valueArg === undefined) { + return; + } + entitiesArg.push({ + id: idArg, + uniqueId: uniqueIdArg, + integrationDomain: 'nanoleaf', + deviceId: deviceIdArg, + platform: 'sensor', + name: nameArg, + state: valueArg, + available: true, + }); + } + + private static controllerDeviceId(snapshotArg: INanoleafSnapshot): string { + return `nanoleaf.controller.${this.slug(snapshotArg.controllerInfo.serialNo || snapshotArg.controllerInfo.name || 'unknown')}`; + } + + private static panelDeviceId(snapshotArg: INanoleafSnapshot, panelArg: INanoleafPanelInfo): string { + return `nanoleaf.panel.${this.slug(snapshotArg.controllerInfo.serialNo || snapshotArg.controllerInfo.name || 'unknown')}.${panelArg.panelId}`; + } + + private static controllerName(snapshotArg: INanoleafSnapshot): string { + return snapshotArg.controllerInfo.name || snapshotArg.controllerInfo.model || 'Nanoleaf'; + } + + private static panels(snapshotArg: INanoleafSnapshot): INanoleafPanelInfo[] { + return snapshotArg.panelLayout?.layout?.positionData ?? []; + } + + private static panelCount(snapshotArg: INanoleafSnapshot): number | undefined { + return snapshotArg.panelLayout?.layout?.numPanels ?? snapshotArg.panelLayout?.layout?.positionData?.length; + } + + private static currentEffect(snapshotArg: INanoleafSnapshot): string | undefined { + const effect = snapshotArg.effects.select; + if (!effect || RESERVED_EFFECTS.has(effect)) { + return undefined; + } + return effect; + } + + private static availableEffects(snapshotArg: INanoleafSnapshot): string[] { + return (snapshotArg.effects.effectsList ?? []).filter((effectArg) => !RESERVED_EFFECTS.has(effectArg)); + } + + private static colorMode(stateArg: INanoleafState): string | undefined { + return this.valueLike(stateArg.colorMode); + } + + private static stateValue(valueArg: INanoleafValue | undefined): TValue | undefined { + return valueArg?.value; + } + + private static valueLike(valueArg: INanoleafValue | TValue | undefined): TValue | undefined { + if (this.isRecord(valueArg) && 'value' in valueArg) { + return valueArg.value as TValue; + } + return valueArg as TValue | undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'nanoleaf'; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/nanoleaf/nanoleaf.types.ts b/ts/integrations/nanoleaf/nanoleaf.types.ts index 575df28..6b24d5a 100644 --- a/ts/integrations/nanoleaf/nanoleaf.types.ts +++ b/ts/integrations/nanoleaf/nanoleaf.types.ts @@ -1,4 +1,185 @@ -export interface IHomeAssistantNanoleafConfig { - // TODO: replace with the TypeScript-native config for nanoleaf. +export type TNanoleafProtocol = 'http' | 'https'; +export type TNanoleafColorMode = 'hs' | 'ct' | 'effect' | string; +export type TNanoleafGesture = 'swipe_up' | 'swipe_down' | 'swipe_left' | 'swipe_right'; + +export interface INanoleafConfig { + host?: string; + port?: number; + protocol?: TNanoleafProtocol; + authToken?: string; + token?: string; + controllerInfo?: INanoleafControllerInfo; + state?: INanoleafState; + effects?: INanoleafEffects; + panelLayout?: INanoleafPanelLayout; + rhythm?: INanoleafRhythmInfo; + snapshot?: INanoleafSnapshot; +} + +export interface INanoleafValue { + value: TValue; + min?: number; + max?: number; +} + +export interface INanoleafState { + on?: INanoleafValue; + brightness?: INanoleafValue; + hue?: INanoleafValue; + sat?: INanoleafValue; + ct?: INanoleafValue; + colorMode?: TNanoleafColorMode | INanoleafValue; +} + +export interface INanoleafPanelInfo { + panelId: number; + x?: number; + y?: number; + o?: number; + shapeType?: number; +} + +export interface INanoleafPanelLayout { + globalOrientation?: INanoleafValue; + layout?: { + numPanels?: number; + sideLength?: number; + positionData?: INanoleafPanelInfo[]; + }; +} + +export interface INanoleafRhythmInfo { + rhythmConnected?: boolean | INanoleafValue; + rhythmActive?: boolean | INanoleafValue; + rhythmId?: number | INanoleafValue; + hardwareVersion?: string | INanoleafValue; + firmwareVersion?: string | INanoleafValue; + auxAvailable?: boolean | INanoleafValue; + rhythmMode?: number | string | INanoleafValue; + rhythmPos?: number | INanoleafValue; +} + +export interface INanoleafEffects { + select?: string; + effectsList?: string[]; + write?: INanoleafEffectsCommand; +} + +export interface INanoleafEffectPaletteColor { + hue?: number; + saturation?: number; + brightness?: number; + probability?: number; +} + +export interface INanoleafEffectPluginOption { + name: string; + value?: string | number | boolean; + type?: string; + defaultValue?: string | number | boolean; + minValue?: number; + maxValue?: number; + strings?: string[]; +} + +export interface INanoleafEffectsCommand { + command: 'add' | 'request' | 'delete' | 'display' | 'displayTemp' | 'rename' | 'requestAll' | 'requestPlugins' | string; + version?: string; + duration?: number; + animName?: string; + newName?: string; + animType?: string; + animData?: string | null; + colorType?: string; + palette?: INanoleafEffectPaletteColor[]; + Palette?: INanoleafEffectPaletteColor[]; + pluginUuid?: string; + pluginType?: string; + pluginOptions?: INanoleafEffectPluginOption[]; + loop?: boolean; [key: string]: unknown; } + +export interface INanoleafStateCommand { + on?: INanoleafValue; + brightness?: INanoleafValue & { duration?: number; increment?: number }; + hue?: INanoleafValue & { increment?: number }; + sat?: INanoleafValue & { increment?: number }; + ct?: INanoleafValue & { increment?: number }; +} + +export interface INanoleafControllerInfo { + name?: string; + serialNo?: string; + manufacturer?: string; + firmwareVersion?: string; + hardwareVersion?: string; + model?: string; + state?: INanoleafState; + effects?: INanoleafEffects; + panelLayout?: INanoleafPanelLayout; + rhythm?: INanoleafRhythmInfo; +} + +export interface INanoleafSnapshot { + controllerInfo: INanoleafControllerInfo; + state: INanoleafState; + effects: INanoleafEffects; + panelLayout?: INanoleafPanelLayout; + rhythm?: INanoleafRhythmInfo; +} + +export interface INanoleafAuthTokenResult { + success: boolean; + authToken?: string; + error?: string; +} + +export interface INanoleafEvent { + type: 'state' | 'effects' | 'layout' | 'touch' | string; + data: TData; + timestamp?: number; +} + +export interface INanoleafStateEventData { + attribute: keyof INanoleafState | string; + value: unknown; +} + +export interface INanoleafEffectsEventData { + effect?: string; + effectsList?: string[]; +} + +export interface INanoleafTouchEventData { + panelId?: number; + gestureId?: number; + gesture?: TNanoleafGesture; +} + +export interface INanoleafMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface INanoleafSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; +} + +export interface INanoleafManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + metadata?: Record; +} + +export type IHomeAssistantNanoleafConfig = INanoleafConfig; diff --git a/ts/integrations/tradfri/.generated-by-smarthome-exchange b/ts/integrations/tradfri/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/tradfri/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/tradfri/index.ts b/ts/integrations/tradfri/index.ts index 4a0041f..85ceced 100644 --- a/ts/integrations/tradfri/index.ts +++ b/ts/integrations/tradfri/index.ts @@ -1,2 +1,6 @@ -export * from './tradfri.classes.integration.js'; +export * from './tradfri.classes.client.js'; +export * from './tradfri.classes.configflow.js'; +export { HomeAssistantTradfriIntegration, TradfriIntegration } from './tradfri.classes.integration.js'; +export * from './tradfri.discovery.js'; +export * from './tradfri.mapper.js'; export * from './tradfri.types.js'; diff --git a/ts/integrations/tradfri/tradfri.classes.client.ts b/ts/integrations/tradfri/tradfri.classes.client.ts new file mode 100644 index 0000000..f8b91d3 --- /dev/null +++ b/ts/integrations/tradfri/tradfri.classes.client.ts @@ -0,0 +1,113 @@ +import type { ITradfriCommand, ITradfriCommandResult, ITradfriConfig, ITradfriEvent, ITradfriGateway, ITradfriManualEntry, ITradfriSnapshot } from './tradfri.types.js'; +import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js'; + +type TTradfriEventHandler = (eventArg: ITradfriEvent) => void; + +export class TradfriClient { + private readonly events: ITradfriEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: ITradfriConfig) {} + + public async getSnapshot(): Promise { + const source = this.config.snapshot; + const manualEntry = this.config.manualEntries?.[0]; + const gateway = this.gatewayFromConfig(source, manualEntry); + return { + host: this.config.host || source?.host || manualEntry?.host, + port: this.config.port || source?.port || manualEntry?.port || tradfriDefaultCoapDtlsPort, + connected: source?.connected ?? false, + gateway, + devices: [ + ...(source?.devices || []), + ...(this.config.devices || []), + ], + groups: [ + ...(source?.groups || []), + ...(this.config.groups || []), + ], + states: source?.states || [], + events: [ + ...(source?.events || []), + ...this.events, + ], + updatedAt: source?.updatedAt, + raw: source?.raw, + }; + } + + public onEvent(handlerArg: TTradfriEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: ITradfriCommand): Promise { + let result: ITradfriCommandResult; + if (this.config.commandExecutor) { + result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + } else { + result = { + success: false, + error: this.unsupportedLiveControlMessage(), + data: { command: commandArg }, + }; + } + this.emit({ + type: result.success ? 'command_mapped' : 'command_failed', + command: commandArg, + data: result, + timestamp: Date.now(), + deviceId: commandArg.deviceId, + entityId: commandArg.entityId, + }); + return result; + } + + public async connectLive(): Promise { + throw new Error(this.unsupportedLiveControlMessage()); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private gatewayFromConfig(sourceArg?: ITradfriSnapshot, manualEntryArg?: ITradfriManualEntry): ITradfriGateway { + const discoveryRecord = this.config.discoveryRecords?.[0]; + const host = this.config.host || sourceArg?.host || manualEntryArg?.host || discoveryRecord?.host; + const configuredGateway = this.config.gateway || sourceArg?.gateway; + return { + ...configuredGateway, + id: configuredGateway?.id || this.config.gatewayId || manualEntryArg?.gatewayId || manualEntryArg?.gateway_id || discoveryRecord?.gatewayId || discoveryRecord?.gateway_id || discoveryRecord?.id || host || 'configured', + name: configuredGateway?.name || manualEntryArg?.name || discoveryRecord?.name || 'IKEA TRADFRI Gateway', + model: configuredGateway?.model || manualEntryArg?.model || discoveryRecord?.model || 'E1526', + }; + } + + private commandResult(resultArg: unknown, commandArg: ITradfriCommand): ITradfriCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is ITradfriCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private emit(eventArg: ITradfriEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private unsupportedLiveControlMessage(): string { + if (this.config.identity && (this.config.psk || this.config.key)) { + return 'IKEA Tradfri live writes require CoAP over DTLS with PSK authentication. This dependency-free port maps snapshot/manual state only unless a commandExecutor is supplied; the mapped command was not sent.'; + } + if (this.config.securityCode) { + return 'IKEA Tradfri pairing requires generating a PSK over CoAP/DTLS from the gateway security code. This dependency-free port does not perform DTLS pairing; the mapped command was not sent.'; + } + return 'IKEA Tradfri live writes require CoAP over DTLS, which is not implemented in this dependency-free port. Supply snapshot data or a commandExecutor to handle mapped commands.'; + } +} diff --git a/ts/integrations/tradfri/tradfri.classes.configflow.ts b/ts/integrations/tradfri/tradfri.classes.configflow.ts new file mode 100644 index 0000000..1c08613 --- /dev/null +++ b/ts/integrations/tradfri/tradfri.classes.configflow.ts @@ -0,0 +1,51 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { ITradfriConfig } from './tradfri.types.js'; +import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js'; + +export class TradfriConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect IKEA TRADFRI Gateway', + description: 'Configure the local gateway. Existing identity plus PSK can be used for an external command executor; security-code pairing over CoAP/DTLS is not performed by this dependency-free port.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'CoAP/DTLS port', type: 'number' }, + { name: 'identity', label: 'Identity', type: 'text' }, + { name: 'securityCode', label: 'Gateway security code', type: 'password' }, + { name: 'psk', label: 'Pre-shared key', type: 'password' }, + ], + submit: async (valuesArg) => { + const psk = this.stringValue(valuesArg.psk) || this.stringValue(candidateArg.metadata?.psk) || this.stringValue(candidateArg.metadata?.key); + const gatewayId = this.stringValue(candidateArg.metadata?.gatewayId) || candidateArg.id; + return { + kind: 'done', + title: 'IKEA TRADFRI configured', + config: { + host: this.stringValue(valuesArg.host) || candidateArg.host || '', + port: this.numberValue(valuesArg.port) || candidateArg.port || tradfriDefaultCoapDtlsPort, + gatewayId, + identity: this.stringValue(valuesArg.identity) || this.stringValue(candidateArg.metadata?.identity), + securityCode: this.stringValue(valuesArg.securityCode) || this.stringValue(candidateArg.metadata?.securityCode), + psk, + key: psk, + gateway: { + id: gatewayId, + name: candidateArg.name || 'IKEA TRADFRI Gateway', + model: candidateArg.model || 'E1526', + }, + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } +} diff --git a/ts/integrations/tradfri/tradfri.classes.integration.ts b/ts/integrations/tradfri/tradfri.classes.integration.ts index e2964f6..27e2dfb 100644 --- a/ts/integrations/tradfri/tradfri.classes.integration.ts +++ b/ts/integrations/tradfri/tradfri.classes.integration.ts @@ -1,24 +1,82 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { TradfriClient } from './tradfri.classes.client.js'; +import { TradfriConfigFlow } from './tradfri.classes.configflow.js'; +import { createTradfriDiscoveryDescriptor } from './tradfri.discovery.js'; +import { TradfriMapper } from './tradfri.mapper.js'; +import type { ITradfriConfig } from './tradfri.types.js'; -export class HomeAssistantTradfriIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "tradfri", - displayName: "IKEA TRÅDFRI", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/tradfri", - "upstreamDomain": "tradfri", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "pytradfri[async]==9.0.1" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, +export class TradfriIntegration extends BaseIntegration { + public readonly domain = 'tradfri'; + public readonly displayName = 'IKEA TR\u00c5DFRI'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createTradfriDiscoveryDescriptor(); + public readonly configFlow = new TradfriConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/tradfri', + upstreamDomain: 'tradfri', + integrationType: 'hub', + iotClass: 'local_polling', + requirements: ['pytradfri[async]==9.0.1'], + dependencies: [] as string[], + afterDependencies: [] as string[], + codeowners: [] as string[], + documentation: 'https://www.home-assistant.io/integrations/tradfri', + homekit: { models: ['TRADFRI'] }, + configFlow: true, + }; + + public async setup(configArg: ITradfriConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new TradfriRuntime(new TradfriClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantTradfriIntegration extends TradfriIntegration {} + +class TradfriRuntime implements IIntegrationRuntime { + public domain = 'tradfri'; + + constructor(private readonly client: TradfriClient) {} + + public async devices(): Promise { + return TradfriMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return TradfriMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.type === 'command_failed' ? 'error' : 'state_changed', + integrationDomain: 'tradfri', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = TradfriMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `IKEA Tradfri service ${requestArg.domain}.${requestArg.service} has no safe native command mapping.` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/tradfri/tradfri.discovery.ts b/ts/integrations/tradfri/tradfri.discovery.ts new file mode 100644 index 0000000..b34e77b --- /dev/null +++ b/ts/integrations/tradfri/tradfri.discovery.ts @@ -0,0 +1,150 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { ITradfriManualEntry, ITradfriMdnsRecord } from './tradfri.types.js'; +import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js'; + +const tradfriMdnsTypes = new Set(['_hap._tcp.local', '_homekit._tcp.local', '_tradfri._udp.local', '_coap._udp.local']); + +export class TradfriMdnsMatcher implements IDiscoveryMatcher { + public id = 'tradfri-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize IKEA TRADFRI gateway mDNS and HomeKit zeroconf records.'; + + public async matches(recordArg: ITradfriMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const txt = recordArg.txt || recordArg.properties || {}; + const name = recordArg.name || recordArg.hostname || ''; + const model = this.txt(txt, 'md') || this.txt(txt, 'model') || this.txt(txt, 'modelid'); + const manufacturer = this.txt(txt, 'manufacturer') || this.txt(txt, 'mfg'); + const gatewayId = this.txt(txt, 'gateway_id') || this.txt(txt, 'gatewayid') || this.txt(txt, 'id') || this.txt(txt, 'serial') || this.txt(txt, 'sn'); + const matched = tradfriMdnsTypes.has(type) && this.containsTradfri(`${name} ${model} ${manufacturer}`) + || type === '_tradfri._udp.local' + || this.containsTradfri(`${name} ${model} ${manufacturer}`) + || Boolean(gatewayId && this.containsTradfri(name)); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not an IKEA TRADFRI gateway advertisement.' }; + } + + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + return { + matched: true, + confidence: gatewayId ? 'certain' : tradfriMdnsTypes.has(type) ? 'high' : 'medium', + reason: 'mDNS record matches IKEA TRADFRI gateway metadata.', + normalizedDeviceId: gatewayId || host || name, + candidate: { + source: 'mdns', + integrationDomain: 'tradfri', + id: gatewayId || host || name, + host, + port: type === '_hap._tcp.local' || type === '_homekit._tcp.local' ? tradfriDefaultCoapDtlsPort : recordArg.port || tradfriDefaultCoapDtlsPort, + name: this.cleanName(name) || 'IKEA TRADFRI Gateway', + manufacturer: 'IKEA of Sweden', + model: model || 'TRADFRI Gateway', + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + gatewayId, + homekitId: this.txt(txt, 'id'), + }, + }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private containsTradfri(valueArg: string): boolean { + const value = valueArg.toLowerCase(); + return value.includes('tradfri') || value.includes('tr\u00e5dfri') || value.includes('ikea'); + } + + private cleanName(valueArg: string): string | undefined { + const cleaned = valueArg.replace(/\._[^.]+\._(?:tcp|udp)\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim(); + return cleaned || undefined; + } +} + +export class TradfriManualMatcher implements IDiscoveryMatcher { + public id = 'tradfri-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual IKEA TRADFRI gateway host and security-code entries.'; + + public async matches(inputArg: ITradfriManualEntry): Promise { + const metadata = inputArg.metadata || {}; + const matched = Boolean(inputArg.host || inputArg.securityCode || inputArg.security_code || inputArg.psk || inputArg.key || inputArg.identity || metadata.tradfri || metadata.securityCode); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain IKEA TRADFRI setup hints.' }; + } + const gatewayId = inputArg.gatewayId || inputArg.gateway_id || inputArg.id; + return { + matched: true, + confidence: inputArg.host && (inputArg.securityCode || inputArg.security_code || inputArg.psk || inputArg.key) ? 'high' : inputArg.host ? 'medium' : 'low', + reason: 'Manual entry can start IKEA TRADFRI setup.', + normalizedDeviceId: gatewayId || inputArg.host, + candidate: { + source: 'manual', + integrationDomain: 'tradfri', + id: gatewayId || inputArg.host, + host: inputArg.host, + port: inputArg.port || tradfriDefaultCoapDtlsPort, + name: inputArg.name || 'IKEA TRADFRI Gateway', + manufacturer: inputArg.manufacturer || 'IKEA of Sweden', + model: inputArg.model || 'TRADFRI Gateway', + metadata: { + ...metadata, + gatewayId, + identity: inputArg.identity, + securityCode: inputArg.securityCode || inputArg.security_code, + psk: inputArg.psk || inputArg.key, + }, + }, + }; + } +} + +export class TradfriCandidateValidator implements IDiscoveryValidator { + public id = 'tradfri-candidate-validator'; + public description = 'Validate IKEA TRADFRI gateway candidates before setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const value = `${candidateArg.integrationDomain || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${candidateArg.name || ''}`.toLowerCase(); + const metadata = candidateArg.metadata || {}; + const matched = candidateArg.integrationDomain === 'tradfri' + || value.includes('tradfri') + || value.includes('tr\u00e5dfri') + || value.includes('ikea') + || Boolean(metadata.tradfri || metadata.securityCode || metadata.psk || metadata.gatewayId); + if (!matched || !candidateArg.host) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'Candidate lacks a host for local TRADFRI setup.' : 'Candidate is not IKEA TRADFRI.', + normalizedDeviceId: candidateArg.id || candidateArg.host, + }; + } + return { + matched: true, + confidence: candidateArg.id ? 'high' : 'medium', + reason: 'Candidate has IKEA TRADFRI gateway metadata and host information.', + candidate: { + ...candidateArg, + port: candidateArg.port || tradfriDefaultCoapDtlsPort, + }, + normalizedDeviceId: candidateArg.id || candidateArg.host, + }; + } +} + +export const createTradfriDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'tradfri', displayName: 'IKEA TR\u00c5DFRI' }) + .addMatcher(new TradfriMdnsMatcher()) + .addMatcher(new TradfriManualMatcher()) + .addValidator(new TradfriCandidateValidator()); +}; diff --git a/ts/integrations/tradfri/tradfri.mapper.ts b/ts/integrations/tradfri/tradfri.mapper.ts new file mode 100644 index 0000000..0744975 --- /dev/null +++ b/ts/integrations/tradfri/tradfri.mapper.ts @@ -0,0 +1,829 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { ITradfriAirPurifier, ITradfriBlind, ITradfriCommand, ITradfriDevice, ITradfriDeviceInfo, ITradfriGateway, ITradfriGroup, ITradfriLight, ITradfriSensor, ITradfriSnapshot, ITradfriSocket, TTradfriResourceType } from './tradfri.types.js'; + +const ROOT_DEVICES = '15001'; +const ROOT_GROUPS = '15004'; +const ATTR_ID = '9003'; +const ATTR_NAME = '9001'; +const ATTR_DEVICE_INFO = '3'; +const ATTR_REACHABLE_STATE = '9019'; +const ATTR_DEVICE_MANUFACTURER = '0'; +const ATTR_DEVICE_MODEL_NUMBER = '1'; +const ATTR_DEVICE_SERIAL = '2'; +const ATTR_DEVICE_FIRMWARE_VERSION = '3'; +const ATTR_DEVICE_BATTERY = '9'; +const ATTR_DEVICE_STATE = '5850'; +const ATTR_LIGHT_CONTROL = '3311'; +const ATTR_LIGHT_DIMMER = '5851'; +const ATTR_LIGHT_COLOR_HEX = '5706'; +const ATTR_LIGHT_COLOR_HUE = '5707'; +const ATTR_LIGHT_COLOR_SATURATION = '5708'; +const ATTR_LIGHT_MIREDS = '5711'; +const ATTR_SWITCH_PLUG = '3312'; +const ATTR_SENSOR = '3300'; +const ATTR_SENSOR_VALUE = '5700'; +const ATTR_SENSOR_UNIT = '5701'; +const ATTR_SENSOR_TYPE = '5751'; +const ATTR_START_BLINDS = '15015'; +const ATTR_BLIND_CURRENT_POSITION = '5536'; +const ROOT_AIR_PURIFIER = '15025'; +const ATTR_AIR_PURIFIER_MODE = '5900'; +const ATTR_AIR_PURIFIER_AIR_QUALITY = '5907'; +const ATTR_AIR_PURIFIER_FAN_SPEED = '5908'; +const ATTR_AIR_PURIFIER_FILTER_LIFETIME_REMAINING = '5910'; +const ATTR_GROUP_MEMBERS = '9018'; +const ATTR_HS_LINK = '15002'; + +export class TradfriMapper { + public static toDevices(snapshotArg: ITradfriSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const gateway = this.gateway(snapshotArg); + const gatewayId = this.gatewayIdentifier(snapshotArg); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.gatewayDevice(snapshotArg, gateway, updatedAt)]; + + for (const [index, device] of snapshotArg.devices.entries()) { + const deviceResourceId = this.deviceResourceId(device, index); + const deviceId = this.deviceId(snapshotArg, device, index); + const deviceInfo = this.deviceInfo(device); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: this.reachable(device) ? 'online' : 'offline', updatedAt }, + ]; + + this.pushOptionalFeature(features, state, 'firmware', 'Firmware', this.stringValue(deviceInfo.firmwareVersion || deviceInfo.firmware_version), undefined, updatedAt); + this.pushOptionalFeature(features, state, 'battery', 'Battery', this.numberValue(deviceInfo.batteryLevel ?? deviceInfo.battery_level), '%', updatedAt); + + for (const [lightIndex, light] of this.lightControls(device).entries()) { + const prefix = this.indexedFeatureId('light', lightIndex); + features.push({ id: `${prefix}_on`, capability: 'light', name: this.indexedName('Light', lightIndex), readable: true, writable: true }); + state.push({ featureId: `${prefix}_on`, value: this.booleanState(this.value(light, ['state', 'on', ATTR_DEVICE_STATE])), updatedAt }); + this.pushOptionalFeature(features, state, `${prefix}_brightness`, `${this.indexedName('Light', lightIndex)} brightness`, this.lightBrightnessPercent(light), '%', updatedAt, 'light', true); + this.pushOptionalFeature(features, state, `${prefix}_color_temp`, `${this.indexedName('Light', lightIndex)} color temperature`, this.numberValue(this.value(light, ['colorTemp', 'color_temp', 'colorMireds', 'color_mireds', ATTR_LIGHT_MIREDS])), 'mired', updatedAt, 'light', true); + } + + for (const [socketIndex, socket] of this.socketControls(device).entries()) { + const prefix = this.indexedFeatureId('outlet', socketIndex); + features.push({ id: `${prefix}_on`, capability: 'switch', name: this.indexedName('Outlet', socketIndex), readable: true, writable: true }); + state.push({ featureId: `${prefix}_on`, value: this.booleanState(this.value(socket, ['state', 'on', ATTR_DEVICE_STATE])), updatedAt }); + } + + for (const [blindIndex, blind] of this.blindControls(device).entries()) { + const prefix = this.indexedFeatureId('cover', blindIndex); + features.push({ id: `${prefix}_position`, capability: 'cover', name: this.indexedName('Blind position', blindIndex), readable: true, writable: true, unit: '%' }); + state.push({ featureId: `${prefix}_position`, value: this.coverPosition(blind), updatedAt }); + } + + for (const [fanIndex, fan] of this.airPurifierControls(device).entries()) { + const prefix = this.indexedFeatureId('fan', fanIndex); + features.push({ id: `${prefix}_speed`, capability: 'fan', name: this.indexedName('Air purifier fan', fanIndex), readable: true, writable: true, unit: '%' }); + state.push({ featureId: `${prefix}_speed`, value: this.fanPercentage(fan), updatedAt }); + this.pushOptionalFeature(features, state, `${prefix}_air_quality`, `${this.indexedName('Air quality', fanIndex)}`, this.airQuality(fan), 'ug/m3', updatedAt); + } + + for (const [sensorIndex, sensor] of this.sensorControls(device).entries()) { + const sensorName = this.sensorName(sensor, sensorIndex); + const sensorFeature = this.indexedFeatureId(this.binarySensor(sensor) ? 'binary_sensor' : 'sensor', sensorIndex); + features.push({ id: sensorFeature, capability: 'sensor', name: sensorName, readable: true, writable: false, unit: this.sensorUnit(sensor) }); + state.push({ featureId: sensorFeature, value: this.deviceStateValue(this.sensorValue(sensor)), updatedAt }); + } + + devices.push({ + id: deviceId, + integrationDomain: 'tradfri', + name: this.deviceName(device, deviceResourceId), + protocol: 'zigbee', + manufacturer: this.stringValue(deviceInfo.manufacturer) || 'IKEA of Sweden', + model: this.stringValue(deviceInfo.modelNumber || deviceInfo.model_number), + online: this.reachable(device), + features, + state, + metadata: { + gatewayId, + tradfriDeviceId: deviceResourceId, + serial: deviceInfo.serial, + powerSource: deviceInfo.powerSource ?? deviceInfo.power_source, + rawDeviceInfo: deviceInfo.raw, + transport: 'coap-dtls', + }, + }); + } + + for (const [index, group] of snapshotArg.groups.entries()) { + const groupResourceId = this.groupResourceId(group, index); + devices.push({ + id: this.groupDeviceId(snapshotArg, group, index), + integrationDomain: 'tradfri', + name: this.groupName(group, groupResourceId), + protocol: 'zigbee', + manufacturer: 'IKEA of Sweden', + model: 'TRADFRI group', + online: snapshotArg.connected ?? true, + features: [ + { id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }, + { id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }, + { id: 'member_count', capability: 'sensor', name: 'Member count', readable: true, writable: false }, + ], + state: [ + { featureId: 'on', value: this.booleanState(this.value(group, ['state', ATTR_DEVICE_STATE])), updatedAt }, + { featureId: 'brightness', value: this.groupBrightnessPercent(group) ?? null, updatedAt }, + { featureId: 'member_count', value: this.groupMemberIds(group).length, updatedAt }, + ], + metadata: { + gatewayId, + tradfriGroupId: groupResourceId, + memberIds: this.groupMemberIds(group), + transport: 'coap-dtls', + }, + }); + } + + return devices; + } + + public static toEntities(snapshotArg: ITradfriSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const [index, device] of snapshotArg.devices.entries()) { + const deviceId = this.deviceId(snapshotArg, device, index); + const deviceResourceId = this.deviceResourceId(device, index); + const deviceSlug = this.slug(this.deviceName(device, deviceResourceId)); + const reachable = this.reachable(device); + const deviceInfo = this.deviceInfo(device); + + for (const [lightIndex, light] of this.lightControls(device).entries()) { + entities.push({ + id: `light.${this.entitySlug(deviceSlug, lightIndex)}`, + uniqueId: `tradfri_light_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${lightIndex}`, + integrationDomain: 'tradfri', + deviceId, + platform: 'light', + name: this.indexedEntityName(this.deviceName(device, deviceResourceId), lightIndex), + state: this.booleanState(this.value(light, ['state', 'on', ATTR_DEVICE_STATE])) ? 'on' : 'off', + attributes: { + brightness: this.numberValue(this.value(light, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])), + brightnessPercent: this.lightBrightnessPercent(light), + colorTempMireds: this.numberValue(this.value(light, ['colorTemp', 'color_temp', 'colorMireds', 'color_mireds', ATTR_LIGHT_MIREDS])), + colorHex: this.stringValue(this.value(light, ['colorHex', 'color_hex', ATTR_LIGHT_COLOR_HEX])), + hue: this.numberValue(this.value(light, ['hue', 'colorHue', 'color_hue', ATTR_LIGHT_COLOR_HUE])), + saturation: this.numberValue(this.value(light, ['saturation', 'colorSaturation', 'color_saturation', ATTR_LIGHT_COLOR_SATURATION])), + tradfriResourceType: 'device', + tradfriResourceId: deviceResourceId, + tradfriControl: 'light', + tradfriIndex: lightIndex, + }, + available: reachable, + }); + } + + for (const [socketIndex, socket] of this.socketControls(device).entries()) { + entities.push({ + id: `switch.${this.entitySlug(deviceSlug, socketIndex)}`, + uniqueId: `tradfri_switch_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${socketIndex}`, + integrationDomain: 'tradfri', + deviceId, + platform: 'switch', + name: this.indexedEntityName(this.deviceName(device, deviceResourceId), socketIndex), + state: this.booleanState(this.value(socket, ['state', 'on', ATTR_DEVICE_STATE])) ? 'on' : 'off', + attributes: { + outlet: true, + tradfriResourceType: 'device', + tradfriResourceId: deviceResourceId, + tradfriControl: 'socket', + tradfriIndex: socketIndex, + }, + available: reachable, + }); + } + + for (const [blindIndex, blind] of this.blindControls(device).entries()) { + const position = this.coverPosition(blind); + entities.push({ + id: `cover.${this.entitySlug(deviceSlug, blindIndex)}`, + uniqueId: `tradfri_cover_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${blindIndex}`, + integrationDomain: 'tradfri', + deviceId, + platform: 'cover', + name: this.indexedEntityName(this.deviceName(device, deviceResourceId), blindIndex), + state: position, + attributes: { + position, + rawPosition: 100 - position, + isClosed: position === 0, + model: this.stringValue(deviceInfo.modelNumber || deviceInfo.model_number), + tradfriResourceType: 'device', + tradfriResourceId: deviceResourceId, + tradfriControl: 'blind', + tradfriIndex: blindIndex, + }, + available: reachable, + }); + } + + this.pushBatteryEntity(entities, snapshotArg, device, deviceInfo, deviceId, deviceSlug, reachable); + + for (const [fanIndex, fan] of this.airPurifierControls(device).entries()) { + const fanSlug = this.entitySlug(deviceSlug, fanIndex); + const percentage = this.fanPercentage(fan); + entities.push({ + id: `fan.${fanSlug}`, + uniqueId: `tradfri_fan_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${fanIndex}`, + integrationDomain: 'tradfri', + deviceId, + platform: 'fan', + name: this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex), + state: this.airPurifierOn(fan) ? 'on' : 'off', + attributes: { + percentage, + presetMode: this.airPurifierAuto(fan) ? 'Auto' : undefined, + airQuality: this.airQuality(fan), + tradfriResourceType: 'device', + tradfriResourceId: deviceResourceId, + tradfriControl: 'air_purifier', + tradfriIndex: fanIndex, + }, + available: reachable, + }); + this.pushSensorEntity(entities, deviceId, `tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_air_quality_${fanIndex}`, `sensor.${fanSlug}_air_quality`, `${this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex)} air quality`, this.airQuality(fan), reachable, { unit: 'ug/m3', deviceClass: 'pm25' }); + this.pushSensorEntity(entities, deviceId, `tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_filter_life_${fanIndex}`, `sensor.${fanSlug}_filter_life_remaining`, `${this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex)} filter life remaining`, this.numberValue(this.value(fan, ['filterLifetimeRemaining', 'filter_lifetime_remaining', ATTR_AIR_PURIFIER_FILTER_LIFETIME_REMAINING])), reachable, { unit: 'min' }); + } + + for (const [sensorIndex, sensor] of this.sensorControls(device).entries()) { + const sensorValue = this.sensorValue(sensor); + const binary = this.binarySensor(sensor); + const platform = binary ? 'binary_sensor' : 'sensor'; + const sensorSlug = `${deviceSlug}_${this.slug(this.sensorName(sensor, sensorIndex))}`; + entities.push({ + id: `${platform}.${sensorSlug}`, + uniqueId: `tradfri_${platform}_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${sensorIndex}`, + integrationDomain: 'tradfri', + deviceId, + platform, + name: this.sensorName(sensor, sensorIndex), + state: binary ? this.binaryState(sensorValue) : sensorValue ?? 'unknown', + attributes: { + unit: this.sensorUnit(sensor), + deviceClass: this.sensorDeviceClass(sensor), + tradfriResourceType: 'device', + tradfriResourceId: deviceResourceId, + tradfriControl: binary ? 'binary_sensor' : 'sensor', + tradfriIndex: sensorIndex, + }, + available: reachable, + }); + } + } + + for (const [index, group] of snapshotArg.groups.entries()) { + const groupResourceId = this.groupResourceId(group, index); + const groupSlug = this.slug(this.groupName(group, groupResourceId)); + entities.push({ + id: `light.${groupSlug}`, + uniqueId: `tradfri_group_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(groupResourceId)}`, + integrationDomain: 'tradfri', + deviceId: this.groupDeviceId(snapshotArg, group, index), + platform: 'light', + name: this.groupName(group, groupResourceId), + state: this.booleanState(this.value(group, ['state', ATTR_DEVICE_STATE])) ? 'on' : 'off', + attributes: { + brightness: this.numberValue(this.value(group, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])), + brightnessPercent: this.groupBrightnessPercent(group), + memberIds: this.groupMemberIds(group), + tradfriResourceType: 'group', + tradfriResourceId: groupResourceId, + tradfriControl: 'group', + tradfriIndex: index, + }, + available: snapshotArg.connected ?? true, + }); + } + + return entities; + } + + public static commandForService(snapshotArg: ITradfriSnapshot, requestArg: IServiceCallRequest): ITradfriCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target || !this.serviceMatchesEntity(target, requestArg)) { + return undefined; + } + const payload = this.payloadForService(target, requestArg); + if (!payload) { + return undefined; + } + const resourceType = target.attributes?.tradfriResourceType === 'group' ? 'group' : 'device'; + const resourceId = this.stringValue(target.attributes?.tradfriResourceId); + if (!resourceId) { + return undefined; + } + return this.commandFromPayload(resourceType, resourceId, target, requestArg, payload); + } + + private static gatewayDevice(snapshotArg: ITradfriSnapshot, gatewayArg: ITradfriGateway, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const gatewayId = this.gatewayIdentifier(snapshotArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false }, + { id: 'group_count', capability: 'sensor', name: 'Group count', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'configured', updatedAt: updatedAtArg }, + { featureId: 'device_count', value: snapshotArg.devices.length, updatedAt: updatedAtArg }, + { featureId: 'group_count', value: snapshotArg.groups.length, updatedAt: updatedAtArg }, + ]; + const firmware = this.stringValue(gatewayArg.firmwareVersion || gatewayArg.firmware_version || this.value(gatewayArg, ['9029'])); + if (firmware) { + features.push({ id: 'firmware', capability: 'sensor', name: 'Firmware', readable: true, writable: false }); + state.push({ featureId: 'firmware', value: firmware, updatedAt: updatedAtArg }); + } + return { + id: `tradfri.gateway.${this.slug(gatewayId)}`, + integrationDomain: 'tradfri', + name: this.stringValue(gatewayArg.name) || 'IKEA TRADFRI Gateway', + protocol: 'zigbee', + manufacturer: 'IKEA of Sweden', + model: this.stringValue(gatewayArg.model) || 'E1526', + online: snapshotArg.connected ?? Boolean(snapshotArg.devices.length || snapshotArg.groups.length), + features, + state, + metadata: { + gatewayId, + host: snapshotArg.host, + port: snapshotArg.port, + homekitId: gatewayArg.homekitId || gatewayArg.homekit_id, + transport: 'coap-dtls', + }, + }; + } + + private static pushBatteryEntity(entitiesArg: IIntegrationEntity[], snapshotArg: ITradfriSnapshot, deviceArg: ITradfriDevice, deviceInfoArg: ITradfriDeviceInfo, deviceIdArg: string, deviceSlugArg: string, reachableArg: boolean): void { + const battery = this.numberValue(deviceInfoArg.batteryLevel ?? deviceInfoArg.battery_level); + if (battery === undefined) { + return; + } + const deviceResourceId = this.deviceResourceId(deviceArg); + this.pushSensorEntity( + entitiesArg, + deviceIdArg, + `tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_battery`, + `sensor.${deviceSlugArg}_battery`, + `${this.deviceName(deviceArg, deviceResourceId)} battery`, + battery, + reachableArg, + { unit: '%', deviceClass: 'battery', tradfriResourceType: 'device', tradfriResourceId: deviceResourceId, tradfriControl: 'battery' } + ); + } + + private static pushSensorEntity(entitiesArg: IIntegrationEntity[], deviceIdArg: string, uniqueIdArg: string, idArg: string, nameArg: string, valueArg: unknown, availableArg: boolean, attributesArg: Record): void { + if (valueArg === undefined || valueArg === null) { + return; + } + entitiesArg.push({ + id: idArg, + uniqueId: uniqueIdArg, + integrationDomain: 'tradfri', + deviceId: deviceIdArg, + platform: 'sensor', + name: nameArg, + state: valueArg, + attributes: attributesArg, + available: availableArg, + }); + } + + private static pushOptionalFeature(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, unitArg: string | undefined, updatedAtArg: string, capabilityArg: plugins.shxInterfaces.data.TDeviceCapability = 'sensor', writableArg = false): void { + if (valueArg === undefined || valueArg === null) { + return; + } + featuresArg.push({ id: idArg, capability: capabilityArg, name: nameArg, readable: true, writable: writableArg, unit: unitArg }); + stateArg.push({ featureId: idArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static findTargetEntity(snapshotArg: ITradfriSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + const candidates = requestArg.target.deviceId ? entities.filter((entityArg) => entityArg.deviceId === requestArg.target.deviceId) : entities; + return candidates.find((entityArg) => this.serviceMatchesEntity(entityArg, requestArg)); + } + + private static serviceMatchesEntity(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): boolean { + if (requestArg.domain !== 'tradfri' && requestArg.domain !== entityArg.platform) { + return false; + } + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + return ['light', 'switch', 'fan'].includes(entityArg.platform); + } + if (requestArg.service === 'set_position' || requestArg.service === 'open_cover' || requestArg.service === 'close_cover') { + return entityArg.platform === 'cover'; + } + if (requestArg.service === 'set_percentage') { + return entityArg.platform === 'fan' || entityArg.platform === 'light'; + } + if (requestArg.service === 'set_value') { + return ['light', 'switch', 'cover', 'fan'].includes(entityArg.platform); + } + return false; + } + + private static payloadForService(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): Record | undefined { + if (requestArg.service === 'turn_on') { + const brightness = this.brightnessPayload(requestArg); + if (entityArg.platform === 'fan') { + return { state: true, percentage: this.numberValue(requestArg.data?.percentage) ?? this.numberValue(entityArg.attributes?.percentage) ?? 100 }; + } + return { state: true, ...brightness }; + } + if (requestArg.service === 'turn_off') { + return { state: false }; + } + if (requestArg.service === 'set_position') { + const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.positionPercentage ?? requestArg.data?.position_percentage); + return position === undefined ? undefined : { position: this.clampPercent(position), rawPosition: 100 - this.clampPercent(position) }; + } + if (requestArg.service === 'open_cover') { + return { position: 100, rawPosition: 0 }; + } + if (requestArg.service === 'close_cover') { + return { position: 0, rawPosition: 100 }; + } + if (requestArg.service === 'set_percentage') { + const percentage = this.numberValue(requestArg.data?.percentage); + if (percentage === undefined) { + return undefined; + } + if (entityArg.platform === 'light') { + return { brightnessPercent: this.clampPercent(percentage), brightness: this.percentToBrightness(percentage), state: percentage > 0 }; + } + return { percentage: this.clampPercent(percentage), mode: this.percentageToFanMode(percentage), state: percentage > 0 }; + } + if (requestArg.service === 'set_value') { + const value = requestArg.data?.value; + if (value === undefined) { + return undefined; + } + if (entityArg.platform === 'cover' && typeof value === 'number') { + const position = this.clampPercent(value); + return { value, position, rawPosition: 100 - position }; + } + if (entityArg.platform === 'fan' && typeof value === 'number') { + return { value, percentage: this.clampPercent(value), mode: this.percentageToFanMode(value), state: value > 0 }; + } + if ((entityArg.platform === 'light' || entityArg.platform === 'switch') && typeof value === 'boolean') { + return { value, state: value }; + } + return { value }; + } + return undefined; + } + + private static commandFromPayload(resourceTypeArg: Exclude, resourceIdArg: string, entityArg: IIntegrationEntity, requestArg: IServiceCallRequest, payloadArg: Record): ITradfriCommand { + const coapPayload = this.coapPayload(resourceTypeArg, entityArg.platform, payloadArg); + return { + type: `${entityArg.platform}.command`, + service: requestArg.service, + resourceType: resourceTypeArg, + resourceId: resourceIdArg, + platform: entityArg.platform, + deviceId: entityArg.deviceId, + entityId: entityArg.id, + uniqueId: entityArg.uniqueId, + payload: payloadArg, + coap: { + method: 'put', + path: [resourceTypeArg === 'group' ? ROOT_GROUPS : ROOT_DEVICES, resourceIdArg], + payload: coapPayload, + }, + target: requestArg.target, + }; + } + + private static coapPayload(resourceTypeArg: Exclude, platformArg: TEntityPlatform, payloadArg: Record): Record { + const values: Record = {}; + if (typeof payloadArg.state === 'boolean') { + values[ATTR_DEVICE_STATE] = payloadArg.state ? 1 : 0; + } + const brightness = this.numberValue(payloadArg.brightness); + if (brightness !== undefined) { + values[ATTR_LIGHT_DIMMER] = this.clamp(brightness, 0, 254); + } + const rawPosition = this.numberValue(payloadArg.rawPosition); + if (platformArg === 'cover' && rawPosition !== undefined) { + return { [ATTR_START_BLINDS]: [{ [ATTR_BLIND_CURRENT_POSITION]: this.clamp(rawPosition, 0, 100) }] }; + } + const mode = this.numberValue(payloadArg.mode); + if (platformArg === 'fan') { + return { [ROOT_AIR_PURIFIER]: [{ [ATTR_AIR_PURIFIER_MODE]: mode ?? (payloadArg.state === false ? 0 : 1) }] }; + } + if (resourceTypeArg === 'group') { + return values; + } + if (platformArg === 'switch') { + return { [ATTR_SWITCH_PLUG]: [values] }; + } + return { [ATTR_LIGHT_CONTROL]: [values] }; + } + + private static gateway(snapshotArg: ITradfriSnapshot): ITradfriGateway { + return snapshotArg.gateway || { id: snapshotArg.host || 'configured', name: 'IKEA TRADFRI Gateway', model: 'E1526' }; + } + + private static gatewayIdentifier(snapshotArg: ITradfriSnapshot): string { + const gateway = this.gateway(snapshotArg); + return this.stringValue(gateway.id || this.value(gateway, ['9081']) || snapshotArg.host) || 'configured'; + } + + private static deviceId(snapshotArg: ITradfriSnapshot, deviceArg: ITradfriDevice, fallbackIndexArg = 0): string { + return `tradfri.device.${this.slug(this.gatewayIdentifier(snapshotArg))}.${this.slug(this.deviceResourceId(deviceArg, fallbackIndexArg))}`; + } + + private static groupDeviceId(snapshotArg: ITradfriSnapshot, groupArg: ITradfriGroup, fallbackIndexArg = 0): string { + return `tradfri.group.${this.slug(this.gatewayIdentifier(snapshotArg))}.${this.slug(this.groupResourceId(groupArg, fallbackIndexArg))}`; + } + + private static deviceResourceId(deviceArg: ITradfriDevice, fallbackIndexArg = 0): string { + return this.stringValue(this.value(deviceArg, ['id', ATTR_ID])) || `device_${fallbackIndexArg}`; + } + + private static groupResourceId(groupArg: ITradfriGroup, fallbackIndexArg = 0): string { + return this.stringValue(this.value(groupArg, ['id', ATTR_ID])) || `group_${fallbackIndexArg}`; + } + + private static deviceName(deviceArg: ITradfriDevice, fallbackArg: string): string { + return this.stringValue(this.value(deviceArg, ['name', ATTR_NAME])) || `IKEA TRADFRI ${fallbackArg}`; + } + + private static groupName(groupArg: ITradfriGroup, fallbackArg: string): string { + return this.stringValue(this.value(groupArg, ['name', ATTR_NAME])) || `IKEA TRADFRI Group ${fallbackArg}`; + } + + private static deviceInfo(deviceArg: ITradfriDevice): ITradfriDeviceInfo { + const rawInfo = this.value(deviceArg, ['deviceInfo', 'device_info', ATTR_DEVICE_INFO]); + const info = this.isRecord(rawInfo) ? rawInfo : {}; + return { + ...info, + manufacturer: this.stringValue(info.manufacturer ?? info[ATTR_DEVICE_MANUFACTURER]), + modelNumber: this.stringValue(info.modelNumber ?? info.model_number ?? info[ATTR_DEVICE_MODEL_NUMBER]), + serial: this.stringValue(info.serial ?? info[ATTR_DEVICE_SERIAL]), + firmwareVersion: this.stringValue(info.firmwareVersion ?? info.firmware_version ?? info[ATTR_DEVICE_FIRMWARE_VERSION]), + powerSource: this.numberValue(info.powerSource ?? info.power_source ?? info['6']), + batteryLevel: this.numberValue(info.batteryLevel ?? info.battery_level ?? info[ATTR_DEVICE_BATTERY]), + raw: info, + }; + } + + private static reachable(deviceArg: ITradfriDevice): boolean { + const value = this.value(deviceArg, ['reachable', ATTR_REACHABLE_STATE]); + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + return true; + } + + private static lightControls(deviceArg: ITradfriDevice): ITradfriLight[] { + return this.arrayValue(this.value(deviceArg, ['lightControl', 'light_control', ATTR_LIGHT_CONTROL])); + } + + private static socketControls(deviceArg: ITradfriDevice): ITradfriSocket[] { + return this.arrayValue(this.value(deviceArg, ['socketControl', 'socket_control', 'outletControl', 'outlet_control', ATTR_SWITCH_PLUG])); + } + + private static blindControls(deviceArg: ITradfriDevice): ITradfriBlind[] { + return this.arrayValue(this.value(deviceArg, ['blindControl', 'blind_control', 'coverControl', 'cover_control', ATTR_START_BLINDS])); + } + + private static airPurifierControls(deviceArg: ITradfriDevice): ITradfriAirPurifier[] { + return this.arrayValue(this.value(deviceArg, ['airPurifierControl', 'air_purifier_control', ROOT_AIR_PURIFIER])); + } + + private static sensorControls(deviceArg: ITradfriDevice): ITradfriSensor[] { + return [ + ...this.arrayValue(this.value(deviceArg, ['sensorControl', 'sensor_control', 'sensors', ATTR_SENSOR])), + ...this.arrayValue(this.value(deviceArg, ['binarySensors', 'binary_sensors'])), + ]; + } + + private static lightBrightnessPercent(lightArg: ITradfriLight): number | undefined { + const percent = this.numberValue(this.value(lightArg, ['brightnessPercent', 'brightness_percent'])); + if (percent !== undefined) { + return this.clampPercent(percent); + } + const raw = this.numberValue(this.value(lightArg, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])); + return raw === undefined ? undefined : Math.round(this.clamp(raw, 0, 254) / 254 * 100); + } + + private static groupBrightnessPercent(groupArg: ITradfriGroup): number | undefined { + const raw = this.numberValue(this.value(groupArg, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])); + return raw === undefined ? undefined : Math.round(this.clamp(raw, 0, 254) / 254 * 100); + } + + private static coverPosition(blindArg: ITradfriBlind): number { + const position = this.numberValue(this.value(blindArg, ['position', 'positionPercentage', 'position_percentage'])); + if (position !== undefined) { + return this.clampPercent(position); + } + const rawPosition = this.numberValue(this.value(blindArg, ['currentCoverPosition', 'current_cover_position', 'rawPosition', 'raw_position', ATTR_BLIND_CURRENT_POSITION])); + return rawPosition === undefined ? 0 : this.clampPercent(100 - rawPosition); + } + + private static airPurifierOn(fanArg: ITradfriAirPurifier): boolean { + const state = this.value(fanArg, ['state']); + if (typeof state === 'boolean') { + return state; + } + const mode = this.numberValue(this.value(fanArg, ['mode', ATTR_AIR_PURIFIER_MODE])); + return mode !== undefined ? mode > 0 : this.fanPercentage(fanArg) > 0; + } + + private static airPurifierAuto(fanArg: ITradfriAirPurifier): boolean { + return this.numberValue(this.value(fanArg, ['mode', ATTR_AIR_PURIFIER_MODE])) === 1; + } + + private static fanPercentage(fanArg: ITradfriAirPurifier): number { + const percentage = this.numberValue(this.value(fanArg, ['percentage'])); + if (percentage !== undefined) { + return this.clampPercent(percentage); + } + const speed = this.numberValue(this.value(fanArg, ['fanSpeed', 'fan_speed', ATTR_AIR_PURIFIER_FAN_SPEED])); + if (speed === undefined) { + return this.airPurifierOn(fanArg) ? 100 : 0; + } + return Math.max(Math.round((speed - 1) / 49 * 100), 0); + } + + private static airQuality(fanArg: ITradfriAirPurifier): number | undefined { + const value = this.numberValue(this.value(fanArg, ['airQuality', 'air_quality', ATTR_AIR_PURIFIER_AIR_QUALITY])); + return value === 65535 ? undefined : value; + } + + private static sensorName(sensorArg: ITradfriSensor, indexArg: number): string { + return this.stringValue(this.value(sensorArg, ['name', 'type', 'deviceClass', 'device_class', ATTR_SENSOR_TYPE])) || `Sensor ${indexArg}`; + } + + private static sensorValue(sensorArg: ITradfriSensor): unknown { + return this.value(sensorArg, ['value', 'state', ATTR_SENSOR_VALUE]); + } + + private static sensorUnit(sensorArg: ITradfriSensor): string | undefined { + return this.stringValue(this.value(sensorArg, ['unit', 'unitOfMeasurement', 'unit_of_measurement', ATTR_SENSOR_UNIT])); + } + + private static sensorDeviceClass(sensorArg: ITradfriSensor): string | undefined { + return this.stringValue(this.value(sensorArg, ['deviceClass', 'device_class', 'type', ATTR_SENSOR_TYPE])); + } + + private static binarySensor(sensorArg: ITradfriSensor): boolean { + if (sensorArg.binary === true) { + return true; + } + const type = `${this.sensorDeviceClass(sensorArg) || ''} ${this.sensorName(sensorArg, 0)}`.toLowerCase(); + if (type.includes('motion') || type.includes('presence') || type.includes('occupancy') || type.includes('contact') || type.includes('open')) { + return true; + } + return typeof this.sensorValue(sensorArg) === 'boolean'; + } + + private static binaryState(valueArg: unknown): 'on' | 'off' { + if (typeof valueArg === 'string') { + return ['on', 'open', 'true', '1', 'detected'].includes(valueArg.toLowerCase()) ? 'on' : 'off'; + } + return valueArg === true || valueArg === 1 ? 'on' : 'off'; + } + + private static groupMemberIds(groupArg: ITradfriGroup): Array { + const direct = this.value(groupArg, ['memberIds', 'member_ids']); + if (Array.isArray(direct)) { + return direct.filter((valueArg) => typeof valueArg === 'string' || typeof valueArg === 'number'); + } + const members = this.value(groupArg, ['groupMembers', 'group_members', ATTR_GROUP_MEMBERS]); + if (this.isRecord(members)) { + const homeSmartLink = members[ATTR_HS_LINK]; + if (this.isRecord(homeSmartLink) && Array.isArray(homeSmartLink[ATTR_ID])) { + return homeSmartLink[ATTR_ID].filter((valueArg) => typeof valueArg === 'string' || typeof valueArg === 'number'); + } + } + return []; + } + + private static brightnessPayload(requestArg: IServiceCallRequest): Record { + const rawBrightness = this.numberValue(requestArg.data?.brightness); + if (rawBrightness !== undefined) { + return { brightness: this.clamp(rawBrightness, 0, 254), brightnessPercent: Math.round(this.clamp(rawBrightness, 0, 254) / 254 * 100) }; + } + const percent = this.numberValue(requestArg.data?.brightnessPct ?? requestArg.data?.brightness_pct ?? requestArg.data?.percentage); + if (percent !== undefined) { + return { brightness: this.percentToBrightness(percent), brightnessPercent: this.clampPercent(percent) }; + } + return {}; + } + + private static percentageToFanMode(valueArg: number): number { + const percentage = this.clampPercent(valueArg); + if (percentage <= 0) { + return 0; + } + return Math.round(Math.max(2, (percentage / 100 * 49) + 1)); + } + + private static percentToBrightness(valueArg: number): number { + return Math.round(this.clampPercent(valueArg) / 100 * 254); + } + + private static indexedFeatureId(prefixArg: string, indexArg: number): string { + return indexArg === 0 ? prefixArg : `${prefixArg}_${indexArg}`; + } + + private static indexedName(nameArg: string, indexArg: number): string { + return indexArg === 0 ? nameArg : `${nameArg} ${indexArg + 1}`; + } + + private static indexedEntityName(nameArg: string, indexArg: number): string { + return indexArg === 0 ? nameArg : `${nameArg} ${indexArg + 1}`; + } + + private static entitySlug(deviceSlugArg: string, indexArg: number): string { + return indexArg === 0 ? deviceSlugArg : `${deviceSlugArg}_${indexArg + 1}`; + } + + private static booleanState(valueArg: unknown): boolean { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg === 1; + } + if (typeof valueArg === 'string') { + return ['1', 'true', 'on'].includes(valueArg.toLowerCase()); + } + return false; + } + + private static value(sourceArg: unknown, keysArg: string[]): unknown { + if (!this.isRecord(sourceArg)) { + return undefined; + } + for (const key of keysArg) { + if (sourceArg[key] !== undefined) { + return sourceArg[key]; + } + } + return undefined; + } + + private static arrayValue(valueArg: unknown): TValue[] { + if (!valueArg) { + return []; + } + if (Array.isArray(valueArg)) { + return valueArg as TValue[]; + } + if (this.isRecord(valueArg)) { + return [valueArg as TValue]; + } + return []; + } + + private static stringValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string' && valueArg) { + return valueArg; + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return String(valueArg); + } + return undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static clampPercent(valueArg: number): number { + return this.clamp(valueArg, 0, 100); + } + + private static clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'tradfri'; + } +} diff --git a/ts/integrations/tradfri/tradfri.types.ts b/ts/integrations/tradfri/tradfri.types.ts index c8649f5..3ae1f16 100644 --- a/ts/integrations/tradfri/tradfri.types.ts +++ b/ts/integrations/tradfri/tradfri.types.ts @@ -1,4 +1,296 @@ -export interface IHomeAssistantTradfriConfig { - // TODO: replace with the TypeScript-native config for tradfri. +import type { IDiscoveryCandidate, TEntityPlatform } from '../../core/types.js'; + +export const tradfriDefaultCoapDtlsPort = 5684; + +export type TTradfriResourceType = 'gateway' | 'device' | 'group'; +export type TTradfriEventType = 'snapshot_updated' | 'command_mapped' | 'command_failed' | 'state_changed' | 'error' | string; + +export interface ITradfriConfig { + host?: string; + port?: number; + gatewayId?: string; + identity?: string; + securityCode?: string; + psk?: string; + key?: string; + gateway?: ITradfriGateway; + devices?: ITradfriDevice[]; + groups?: ITradfriGroup[]; + snapshot?: ITradfriSnapshot; + discoveryRecords?: ITradfriDiscoveryRecord[]; + manualEntries?: ITradfriManualEntry[]; + commandExecutor?: TTradfriCommandExecutor; +} + +export interface IHomeAssistantTradfriConfig extends ITradfriConfig {} + +export interface ITradfriGateway { + id?: string; + name?: string; + model?: string; + firmwareVersion?: string; + firmware_version?: string; + homekitId?: string; + homekit_id?: string; + currentTime?: string | number; + current_time?: string | number; + commissioningMode?: number; + commissioning_mode?: number; + raw?: Record; [key: string]: unknown; } + +export interface ITradfriDeviceInfo { + manufacturer?: string; + modelNumber?: string; + model_number?: string; + serial?: string; + firmwareVersion?: string; + firmware_version?: string; + powerSource?: number; + power_source?: number; + batteryLevel?: number; + battery_level?: number; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriLight { + id?: string | number; + index?: number; + state?: boolean | number; + on?: boolean; + dimmer?: number; + brightness?: number; + brightnessPercent?: number; + brightness_percent?: number; + colorTemp?: number; + color_temp?: number; + colorMireds?: number; + color_mireds?: number; + colorHex?: string; + color_hex?: string; + hue?: number; + saturation?: number; + colorHue?: number; + color_hue?: number; + colorSaturation?: number; + color_saturation?: number; + xy?: [number, number]; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriSocket { + id?: string | number; + index?: number; + state?: boolean | number; + on?: boolean; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriBlind { + id?: string | number; + index?: number; + position?: number; + positionPercentage?: number; + position_percentage?: number; + currentCoverPosition?: number; + current_cover_position?: number; + rawPosition?: number; + raw_position?: number; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriSensor { + id?: string | number; + index?: number; + name?: string; + type?: string; + deviceClass?: string; + device_class?: string; + unit?: string; + unitOfMeasurement?: string; + unit_of_measurement?: string; + value?: string | number | boolean | null; + state?: string | number | boolean | null; + binary?: boolean; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriBinarySensor extends ITradfriSensor { + binary: true; +} + +export interface ITradfriAirPurifier { + id?: string | number; + index?: number; + state?: boolean | number; + mode?: number; + fanSpeed?: number; + fan_speed?: number; + percentage?: number; + airQuality?: number; + air_quality?: number; + filterLifetimeRemaining?: number; + filter_lifetime_remaining?: number; + filterLifetimeTotal?: number; + filter_lifetime_total?: number; + controlsLocked?: boolean | number; + controls_locked?: boolean | number; + ledsOff?: boolean | number; + leds_off?: boolean | number; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriDevice { + id?: string | number; + name?: string; + applicationType?: number; + application_type?: number; + reachable?: boolean | number; + lastSeen?: string | number; + last_seen?: string | number; + createdAt?: string | number; + created_at?: string | number; + deviceInfo?: ITradfriDeviceInfo; + device_info?: ITradfriDeviceInfo; + lightControl?: ITradfriLight[]; + light_control?: ITradfriLight[]; + socketControl?: ITradfriSocket[]; + socket_control?: ITradfriSocket[]; + outletControl?: ITradfriSocket[]; + outlet_control?: ITradfriSocket[]; + blindControl?: ITradfriBlind[]; + blind_control?: ITradfriBlind[]; + coverControl?: ITradfriBlind[]; + cover_control?: ITradfriBlind[]; + sensorControl?: ITradfriSensor[]; + sensor_control?: ITradfriSensor[]; + sensors?: ITradfriSensor[]; + binarySensors?: ITradfriBinarySensor[]; + binary_sensors?: ITradfriBinarySensor[]; + airPurifierControl?: ITradfriAirPurifier[]; + air_purifier_control?: ITradfriAirPurifier[]; + signalRepeaterControl?: unknown[]; + signal_repeater_control?: unknown[]; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriGroup { + id?: string | number; + name?: string; + state?: boolean | number; + dimmer?: number; + brightness?: number; + colorHex?: string; + color_hex?: string; + memberIds?: Array; + member_ids?: Array; + groupMembers?: Record; + group_members?: Record; + moodId?: string | number; + mood_id?: string | number; + raw?: Record; + [key: string]: unknown; +} + +export interface ITradfriSnapshot { + host?: string; + port?: number; + connected?: boolean; + gateway?: ITradfriGateway; + devices: ITradfriDevice[]; + groups: ITradfriGroup[]; + states?: ITradfriState[]; + events?: ITradfriEvent[]; + updatedAt?: string | number; + raw?: Record; +} + +export interface ITradfriState { + resourceType: TTradfriResourceType; + resourceId: string; + featureId: string; + value: unknown; + updatedAt?: string | number; +} + +export interface ITradfriCommand { + type: string; + service: string; + resourceType: Exclude; + resourceId: string; + platform?: TEntityPlatform; + deviceId?: string; + entityId?: string; + uniqueId?: string; + payload: Record; + coap: { + method: 'put' | 'post' | 'get'; + path: string[]; + payload?: Record; + }; + target?: { + entityId?: string; + deviceId?: string; + }; +} + +export interface ITradfriCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export type TTradfriCommandExecutor = ( + commandArg: ITradfriCommand +) => Promise | ITradfriCommandResult | unknown; + +export interface ITradfriEvent { + type: TTradfriEventType; + timestamp?: number; + deviceId?: string; + entityId?: string; + command?: ITradfriCommand; + data?: unknown; +} + +export interface ITradfriMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface ITradfriManualEntry { + host?: string; + port?: number; + id?: string; + gatewayId?: string; + gateway_id?: string; + name?: string; + model?: string; + manufacturer?: string; + identity?: string; + securityCode?: string; + security_code?: string; + psk?: string; + key?: string; + metadata?: Record; +} + +export interface ITradfriDiscoveryRecord extends ITradfriManualEntry { + source?: IDiscoveryCandidate['source'] | string; + type?: string; + txt?: Record; +} diff --git a/ts/integrations/wiz/.generated-by-smarthome-exchange b/ts/integrations/wiz/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/wiz/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/wiz/index.ts b/ts/integrations/wiz/index.ts index 48667dc..ded705a 100644 --- a/ts/integrations/wiz/index.ts +++ b/ts/integrations/wiz/index.ts @@ -1,2 +1,6 @@ +export * from './wiz.classes.client.js'; +export * from './wiz.classes.configflow.js'; export * from './wiz.classes.integration.js'; +export * from './wiz.discovery.js'; +export * from './wiz.mapper.js'; export * from './wiz.types.js'; diff --git a/ts/integrations/wiz/wiz.classes.client.ts b/ts/integrations/wiz/wiz.classes.client.ts new file mode 100644 index 0000000..192735c --- /dev/null +++ b/ts/integrations/wiz/wiz.classes.client.ts @@ -0,0 +1,278 @@ +import type { + IWizClientCommand, + IWizCommandResult, + IWizConfig, + IWizDeviceInfo, + IWizEvent, + IWizPilotPatch, + IWizPilotState, + IWizSnapshot, + IWizSnapshotDevice, + IWizUdpCommand, + IWizUdpResponse, +} from './wiz.types.js'; +import { wizDefaultPort } from './wiz.types.js'; +import { WizMapper } from './wiz.mapper.js'; + +type TWizEventHandler = (eventArg: IWizEvent) => void; + +export class WizClient { + private readonly events: IWizEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IWizConfig) {} + + public async getSnapshot(): Promise { + const host = this.host(); + if (!host) { + return WizMapper.toSnapshot(this.config, false, this.events); + } + + try { + const pilot = await this.getPilot(); + const deviceInfo = await this.liveDeviceInfo(pilot).catch(() => this.staticDeviceInfo(pilot)); + const device: IWizSnapshotDevice = { + host, + port: this.port(), + mac: pilot.mac || deviceInfo.mac || this.config.mac, + name: this.config.name || deviceInfo.name, + deviceInfo, + pilot, + available: true, + }; + return WizMapper.toSnapshot({ ...this.config, snapshot: undefined, devices: [device], manualEntries: undefined }, true, this.events); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit({ type: 'error', data: { message }, timestamp: Date.now() }); + return WizMapper.toSnapshot(this.config, false, this.events); + } + } + + public onEvent(handlerArg: TWizEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async getPilot(): Promise { + const response = await this.sendUdp({ method: 'getPilot', params: {} }); + const result = this.record(response.result) ? response.result as IWizPilotState : {}; + this.emit({ type: 'pilot', data: result, timestamp: Date.now() }); + return result; + } + + public async setPilot(payloadArg: IWizPilotPatch): Promise>> { + return this.sendUdp>({ method: 'setPilot', params: payloadArg as Record }); + } + + public async getSystemConfig(): Promise>> { + return this.sendUdp>({ method: 'getSystemConfig', params: {} }); + } + + public async sendCommand(commandArg: IWizClientCommand): Promise { + let result: IWizCommandResult; + if (this.config.commandExecutor) { + result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + } else if (!this.host()) { + result = { + success: false, + error: this.unsupportedLiveControlMessage(), + data: { command: commandArg }, + }; + } else { + try { + const response = await this.setPilot(commandArg.payload); + result = { success: true, data: response.result || response }; + } catch (error) { + result = { + success: false, + error: error instanceof Error ? error.message : String(error), + data: { command: commandArg }, + }; + } + } + + this.emit({ + type: result.success ? 'command_mapped' : 'command_failed', + command: commandArg, + data: result, + timestamp: Date.now(), + deviceId: commandArg.deviceId, + entityId: commandArg.entityId, + }); + return result; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async liveDeviceInfo(pilotArg: IWizPilotState): Promise { + const systemConfig = await this.getSystemConfig(); + const result = this.record(systemConfig.result) ? systemConfig.result : {}; + return this.staticDeviceInfo(pilotArg, result); + } + + private staticDeviceInfo(pilotArg?: IWizPilotState, systemArg: Record = {}): IWizDeviceInfo { + const moduleName = this.stringValue(systemArg.moduleName) || this.config.deviceInfo?.moduleName; + const model = this.config.deviceInfo?.model || moduleName; + const isSocket = this.config.deviceInfo?.isSocket ?? this.textContainsSocket(model, moduleName, systemArg.typeId); + return { + ...this.config.deviceInfo, + host: this.host(), + port: this.port(), + mac: this.stringValue(systemArg.mac) || pilotArg?.mac || this.config.mac || this.config.deviceInfo?.mac, + name: this.config.name || this.config.deviceInfo?.name, + manufacturer: this.config.deviceInfo?.manufacturer || 'WiZ', + model, + moduleName, + fwVersion: this.stringValue(systemArg.fwVersion) || this.config.deviceInfo?.fwVersion, + typeId: this.stringValue(systemArg.typeId) || this.numberValue(systemArg.typeId) || this.config.deviceInfo?.typeId, + isSocket, + features: { + light: !isSocket, + switch: isSocket, + brightness: !isSocket || typeof pilotArg?.dimming === 'number', + color: ['r', 'g', 'b'].every((keyArg) => typeof pilotArg?.[keyArg] === 'number') || this.textContains(moduleName, 'rgb'), + colorTemp: typeof pilotArg?.temp === 'number' || this.textContains(moduleName, 'tw'), + effect: typeof pilotArg?.sceneId === 'number' || typeof pilotArg?.schdPsetId === 'number', + fan: typeof pilotArg?.fanState === 'number', + power: typeof pilotArg?.pc === 'number', + occupancy: pilotArg?.src === 'pir', + ...this.config.deviceInfo?.features, + }, + }; + } + + private async sendUdp(commandArg: IWizUdpCommand): Promise> { + const host = this.host(); + if (!host) { + throw new Error(this.unsupportedLiveControlMessage()); + } + const port = this.port(); + const timeoutMs = this.timeoutMs(); + const { createSocket } = await import('node:dgram'); + const payload = Buffer.from(JSON.stringify(commandArg)); + + return new Promise>((resolve, reject) => { + const socket = createSocket('udp4'); + const timers: Array> = []; + let settled = false; + + const cleanup = () => { + for (const timer of timers) { + clearTimeout(timer); + } + socket.removeAllListeners(); + try { + socket.close(); + } catch { + // The socket may already be closed after an early UDP error. + } + }; + + const finish = (errorArg: Error | undefined, responseArg?: IWizUdpResponse) => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (errorArg) { + reject(errorArg); + } else { + resolve(responseArg || {}); + } + }; + + const send = () => { + if (settled) { + return; + } + socket.send(payload, port, host, (errorArg) => { + if (errorArg) { + finish(errorArg); + } + }); + }; + + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('message', (messageArg) => { + let response: IWizUdpResponse; + try { + response = JSON.parse(messageArg.toString('utf8')) as IWizUdpResponse; + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + return; + } + if (response.method && response.method !== commandArg.method) { + return; + } + if (response.error) { + finish(new Error(response.error.message || `WiZ UDP error ${response.error.code ?? 'unknown'}`)); + return; + } + this.emit({ type: 'udp_response', data: response, timestamp: Date.now() }); + finish(undefined, response); + }); + + send(); + timers.push(setTimeout(send, 750)); + timers.push(setTimeout(send, 2250)); + timers.push(setTimeout(() => finish(new Error(`Timed out waiting for WiZ ${commandArg.method} response from ${host}:${port}.`)), timeoutMs)); + }); + } + + private emit(eventArg: IWizEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IWizClientCommand): IWizCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IWizCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private host(): string | undefined { + return this.config.host || this.config.manualEntries?.find((entryArg) => entryArg.host)?.host; + } + + private port(): number { + const manualPort = this.config.manualEntries?.find((entryArg) => entryArg.host)?.port; + return this.config.port || manualPort || wizDefaultPort; + } + + private timeoutMs(): number { + return typeof this.config.timeoutMs === 'number' && this.config.timeoutMs > 0 ? this.config.timeoutMs : 5000; + } + + private unsupportedLiveControlMessage(): string { + return 'WiZ live UDP control requires a configured host. Snapshot-only WiZ configs are read-only unless commandExecutor is provided.'; + } + + private textContainsSocket(...valuesArg: unknown[]): boolean { + return valuesArg.some((valueArg) => this.textContains(valueArg, 'socket') || this.textContains(valueArg, 'plug')); + } + + private textContains(valueArg: unknown, fragmentArg: string): boolean { + return typeof valueArg === 'string' && valueArg.toLowerCase().includes(fragmentArg); + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private record(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/wiz/wiz.classes.configflow.ts b/ts/integrations/wiz/wiz.classes.configflow.ts new file mode 100644 index 0000000..d6795f6 --- /dev/null +++ b/ts/integrations/wiz/wiz.classes.configflow.ts @@ -0,0 +1,84 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IWizConfig, IWizDeviceInfo, IWizPilotState, IWizSnapshot } from './wiz.types.js'; +import { wizDefaultPort } from './wiz.types.js'; + +export class WizConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaults = this.defaultsFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Connect WiZ', + description: 'Configure the WiZ device host. Local control uses UDP JSON on port 38899 by default.', + fields: [ + { name: 'host', label: 'Host or IP address', type: 'text', required: true }, + { name: 'port', label: 'UDP port (default 38899)', type: 'number' }, + { name: 'mac', label: 'MAC address', type: 'text' }, + { name: 'name', label: 'Device name', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || defaults.host; + if (!host) { + return { kind: 'error', title: 'Invalid WiZ config', error: 'WiZ setup requires a host or IP address.' }; + } + const port = this.numberValue(valuesArg.port) || defaults.port || wizDefaultPort; + const mac = this.stringValue(valuesArg.mac) || defaults.mac; + const name = this.stringValue(valuesArg.name) || defaults.name; + return { + kind: 'done', + title: 'WiZ configured', + config: { + host, + port, + mac, + name, + deviceInfo: { + ...defaults.deviceInfo, + host, + port, + mac: mac || defaults.deviceInfo?.mac, + name: name || defaults.deviceInfo?.name, + }, + pilot: defaults.pilot, + snapshot: defaults.snapshot, + }, + }; + }, + }; + } + + private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { host?: string; port?: number; mac?: string; name?: string; deviceInfo?: IWizDeviceInfo; pilot?: IWizPilotState; snapshot?: IWizSnapshot } { + const metadata = candidateArg.metadata || {}; + const deviceInfo = this.isRecord(metadata.deviceInfo) ? metadata.deviceInfo as unknown as IWizDeviceInfo : undefined; + const pilot = this.isRecord(metadata.pilot) ? metadata.pilot as unknown as IWizPilotState : undefined; + const snapshot = this.isRecord(metadata.snapshot) ? metadata.snapshot as unknown as IWizSnapshot : undefined; + return { + host: candidateArg.host, + port: candidateArg.port || wizDefaultPort, + mac: candidateArg.macAddress, + name: candidateArg.name, + deviceInfo, + pilot, + snapshot, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/wiz/wiz.classes.integration.ts b/ts/integrations/wiz/wiz.classes.integration.ts index 8a09640..9800a96 100644 --- a/ts/integrations/wiz/wiz.classes.integration.ts +++ b/ts/integrations/wiz/wiz.classes.integration.ts @@ -1,29 +1,80 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { WizClient } from './wiz.classes.client.js'; +import { WizConfigFlow } from './wiz.classes.configflow.js'; +import { createWizDiscoveryDescriptor } from './wiz.discovery.js'; +import { WizMapper } from './wiz.mapper.js'; +import type { IWizConfig } from './wiz.types.js'; -export class HomeAssistantWizIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "wiz", - displayName: "WiZ", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/wiz", - "upstreamDomain": "wiz", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "pywizlight==0.6.3" - ], - "dependencies": [ - "network" - ], - "afterDependencies": [], - "codeowners": [ - "@sbidy", - "@arturpragacz" - ] -}, - }); +export class WizIntegration extends BaseIntegration { + public readonly domain = 'wiz'; + public readonly displayName = 'WiZ'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createWizDiscoveryDescriptor(); + public readonly configFlow = new WizConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/wiz', + upstreamDomain: 'wiz', + integrationType: 'device', + iotClass: 'local_push', + requirements: ['pywizlight==0.6.3'], + dependencies: ['network'], + afterDependencies: [] as string[], + codeowners: ['@sbidy', '@arturpragacz'], + documentation: 'https://www.home-assistant.io/integrations/wiz', + protocolDocumentation: 'https://docs.pro.wizconnected.com/#introduction', + dhcp: [ + { registeredDevices: true }, + { macaddress: 'A8BB50*' }, + { macaddress: 'D8A011*' }, + { macaddress: '444F8E*' }, + { macaddress: '6C2990*' }, + { hostname: 'wiz_*' }, + ], + }; + + public async setup(configArg: IWizConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new WizRuntime(new WizClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantWizIntegration extends WizIntegration {} + +class WizRuntime implements IIntegrationRuntime { + public domain = 'wiz'; + + constructor(private readonly client: WizClient) {} + + public async devices(): Promise { + return WizMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return WizMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(WizMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = WizMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `WiZ service ${requestArg.domain}.${requestArg.service} has no native setPilot mapping for the target.` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/wiz/wiz.discovery.ts b/ts/integrations/wiz/wiz.discovery.ts new file mode 100644 index 0000000..b183c9d --- /dev/null +++ b/ts/integrations/wiz/wiz.discovery.ts @@ -0,0 +1,314 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { + IDiscoveryCandidate, + IDiscoveryContext, + IDiscoveryMatch, + IDiscoveryMatcher, + IDiscoveryProbe, + IDiscoveryProbeResult, + IDiscoveryValidator, +} from '../../core/types.js'; +import type { IWizManualEntry, IWizMdnsRecord, IWizUdpDiscoveryRecord, IWizUdpResponse } from './wiz.types.js'; +import { wizDefaultPort } from './wiz.types.js'; + +const wizMacPrefixes = ['a8bb50', 'd8a011', '444f8e', '6c2990']; +const wizUdpMethods = new Set(['getPilot', 'setPilot', 'syncPilot', 'syncSystemConfig', 'getSystemConfig', 'registration']); + +export class WizUdpDiscoveryProbe implements IDiscoveryProbe { + public id = 'wiz-udp-discovery-probe'; + public source = 'custom' as const; + public description = 'Discover WiZ devices by UDP registration broadcast on port 38899.'; + + public async probe(contextArg: IDiscoveryContext): Promise { + if (contextArg.abortSignal?.aborted) { + return { candidates: [] }; + } + return { candidates: await this.discover(1200) }; + } + + private async discover(timeoutMsArg: number): Promise { + const { createSocket } = await import('node:dgram'); + const message = Buffer.from(JSON.stringify({ + method: 'registration', + params: { + phoneMac: 'AAAAAAAAAAAA', + register: false, + phoneIp: '1.2.3.4', + id: '1', + }, + })); + const matcher = new WizUdpMatcher(); + const candidates: IDiscoveryCandidate[] = []; + + return new Promise((resolve) => { + const socket = createSocket({ type: 'udp4', reuseAddr: true }); + const timer = setTimeout(() => { + try { + socket.close(); + } catch { + // The discovery socket may already be closed after an OS error. + } + resolve(candidates); + }, timeoutMsArg); + + socket.on('message', async (dataArg, remoteArg) => { + let response: IWizUdpResponse> | undefined; + try { + response = JSON.parse(dataArg.toString('utf8')) as IWizUdpResponse>; + } catch { + return; + } + const match = await matcher.matches({ host: remoteArg.address, port: remoteArg.port, response }); + if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) { + candidates.push(match.candidate); + } + }); + socket.on('error', () => { + clearTimeout(timer); + try { + socket.close(); + } catch { + // Ignore discovery socket close races. + } + resolve(candidates); + }); + socket.bind(() => { + socket.setBroadcast(true); + socket.send(message, wizDefaultPort, '255.255.255.255'); + setTimeout(() => socket.send(message, wizDefaultPort, '255.255.255.255'), 500); + }); + }); + } +} + +export class WizMdnsMatcher implements IDiscoveryMatcher { + public id = 'wiz-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize WiZ mDNS or hostname advertisements.'; + + public async matches(recordArg: IWizMdnsRecord): Promise { + const txt = recordArg.txt || recordArg.properties || {}; + const type = this.normalizeType(recordArg.type); + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const mac = this.normalizeMac(this.txt(txt, 'mac') || this.txt(txt, 'macaddress') || this.txt(txt, 'mac_address')); + const name = this.name(recordArg, txt); + const text = [type, recordArg.name, recordArg.hostname, host, name, this.txt(txt, 'manufacturer'), this.txt(txt, 'model')] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const matched = Boolean(mac && this.isWizMac(mac)) || /(^|[._-])wiz([._-]|$)/i.test(text) || text.includes('wizconnected'); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a WiZ advertisement.' }; + } + return { + matched: true, + confidence: mac ? 'certain' : host ? 'high' : 'medium', + reason: mac ? 'mDNS record contains a WiZ MAC address.' : 'mDNS record contains WiZ hostname or TXT metadata.', + normalizedDeviceId: mac || recordArg.name || host, + candidate: { + source: 'mdns', + integrationDomain: 'wiz', + id: mac || recordArg.name || host, + host, + port: recordArg.port || wizDefaultPort, + name: name || 'WiZ', + manufacturer: 'WiZ', + model: this.txt(txt, 'model') || this.txt(txt, 'moduleName') || 'WiZ device', + macAddress: mac, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + }, + }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } + + private name(recordArg: IWizMdnsRecord, txtArg: Record): string | undefined { + return this.txt(txtArg, 'name') || this.txt(txtArg, 'friendly_name') || recordArg.name?.split('._', 1)[0] || recordArg.hostname?.replace(/\.local\.?$/i, ''); + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + private isWizMac(valueArg: string): boolean { + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return wizMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg)); + } +} + +export class WizUdpMatcher implements IDiscoveryMatcher { + public id = 'wiz-udp-match'; + public source = 'custom' as const; + public description = 'Recognize WiZ UDP JSON discovery responses.'; + + public async matches(recordArg: IWizUdpDiscoveryRecord): Promise { + const response = recordArg.response; + const result = response?.result || recordArg.result || {}; + const method = response?.method || recordArg.method; + const host = recordArg.host || recordArg.address || recordArg.ip || recordArg.ip_address; + const mac = this.normalizeMac(recordArg.mac || recordArg.mac_address || this.stringValue(result.mac)); + const matched = Boolean(mac) || Boolean(method && wizUdpMethods.has(method)) || recordArg.metadata?.wiz === true; + if (!matched) { + return { matched: false, confidence: 'low', reason: 'UDP record is not a WiZ JSON response.' }; + } + return { + matched: true, + confidence: mac && host ? 'certain' : mac || host ? 'high' : 'medium', + reason: mac ? 'UDP response contains a WiZ MAC address.' : 'UDP response method matches WiZ JSON protocol.', + normalizedDeviceId: mac || recordArg.name || host, + candidate: { + source: 'custom', + integrationDomain: 'wiz', + id: mac || recordArg.name || host, + host, + port: recordArg.port || wizDefaultPort, + name: recordArg.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'), + manufacturer: 'WiZ', + model: 'WiZ UDP JSON device', + macAddress: mac, + metadata: { + ...recordArg.metadata, + discoveryProtocol: 'udp', + method, + result, + }, + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private shortMac(valueArg?: string): string { + return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase(); + } + + private normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } +} + +export class WizManualMatcher implements IDiscoveryMatcher { + public id = 'wiz-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual WiZ setup entries.'; + + public async matches(inputArg: IWizManualEntry): Promise { + const mac = this.normalizeMac(inputArg.mac || inputArg.macAddress || inputArg.deviceInfo?.mac); + const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.deviceInfo?.manufacturer, inputArg.deviceInfo?.model, inputArg.deviceInfo?.moduleName] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const matched = Boolean(inputArg.host || mac || inputArg.metadata?.wiz || text.includes('wiz')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain WiZ setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host && mac ? 'certain' : inputArg.host || mac ? 'high' : 'medium', + reason: 'Manual entry can start WiZ setup.', + normalizedDeviceId: mac || inputArg.id || inputArg.host, + candidate: { + source: 'manual', + integrationDomain: 'wiz', + id: inputArg.id || mac || inputArg.host, + host: inputArg.host, + port: inputArg.port || wizDefaultPort, + name: inputArg.name || inputArg.deviceInfo?.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'), + manufacturer: inputArg.manufacturer || inputArg.deviceInfo?.manufacturer || 'WiZ', + model: inputArg.model || inputArg.deviceInfo?.model || inputArg.deviceInfo?.moduleName || 'WiZ device', + macAddress: mac, + metadata: { + ...inputArg.metadata, + discoveryProtocol: 'manual', + deviceInfo: inputArg.deviceInfo, + pilot: inputArg.pilot, + snapshot: inputArg.snapshot, + }, + }, + }; + } + + private shortMac(valueArg?: string): string { + return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase(); + } + + private normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } +} + +export class WizCandidateValidator implements IDiscoveryValidator { + public id = 'wiz-candidate-validator'; + public description = 'Validate WiZ candidates from mDNS, UDP, DHCP, and manual setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const mac = this.normalizeMac(candidateArg.macAddress); + const compactMac = (mac || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + const name = (candidateArg.name || '').toLowerCase(); + const model = (candidateArg.model || '').toLowerCase(); + const manufacturer = (candidateArg.manufacturer || '').toLowerCase(); + const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : ''; + const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined; + const macMatched = wizMacPrefixes.some((prefixArg) => compactMac.startsWith(prefixArg)); + const hostMatched = Boolean(candidateArg.host && /^wiz[_-]/i.test(candidateArg.host)); + const textMatched = name.includes('wiz') || model.includes('wiz') || manufacturer.includes('wiz') || mdnsType.includes('wiz'); + const matched = candidateArg.integrationDomain === 'wiz' + || macMatched + || hostMatched + || textMatched + || candidateArg.port === wizDefaultPort + || metadata.wiz === true + || discoveryProtocol === 'udp' + || discoveryProtocol === 'manual'; + return { + matched, + confidence: matched && (candidateArg.integrationDomain === 'wiz' || macMatched) && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has WiZ metadata or local UDP port information.' : 'Candidate is not WiZ.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: mac || candidateArg.id || candidateArg.host, + metadata: matched ? { macMatched, hostMatched, discoveryProtocol } : undefined, + }; + } + + private normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } +} + +export const createWizDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'wiz', displayName: 'WiZ' }) + .addProbe(new WizUdpDiscoveryProbe()) + .addMatcher(new WizMdnsMatcher()) + .addMatcher(new WizUdpMatcher()) + .addMatcher(new WizManualMatcher()) + .addValidator(new WizCandidateValidator()); +}; diff --git a/ts/integrations/wiz/wiz.mapper.ts b/ts/integrations/wiz/wiz.mapper.ts new file mode 100644 index 0000000..74878f4 --- /dev/null +++ b/ts/integrations/wiz/wiz.mapper.ts @@ -0,0 +1,764 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IWizButtonRecord, + IWizClientCommand, + IWizConfig, + IWizDeviceFeatures, + IWizDeviceInfo, + IWizEntityRecord, + IWizEvent, + IWizManualEntry, + IWizPilotPatch, + IWizPilotState, + IWizSensorRecord, + IWizSnapshot, + IWizSnapshotDevice, +} from './wiz.types.js'; +import { wizDefaultPort } from './wiz.types.js'; + +const wizScenes: Array<{ id: number; name: string }> = [ + { id: 35, name: 'Alarm' }, + { id: 10, name: 'Bedtime' }, + { id: 29, name: 'Candlelight' }, + { id: 27, name: 'Christmas' }, + { id: 6, name: 'Cozy' }, + { id: 13, name: 'Cool white' }, + { id: 26, name: 'Club' }, + { id: 12, name: 'Daylight' }, + { id: 33, name: 'Diwali' }, + { id: 23, name: 'Deep dive' }, + { id: 22, name: 'Fall' }, + { id: 5, name: 'Fireplace' }, + { id: 7, name: 'Forest' }, + { id: 15, name: 'Focus' }, + { id: 30, name: 'Golden white' }, + { id: 28, name: 'Halloween' }, + { id: 24, name: 'Jungle' }, + { id: 25, name: 'Mojito' }, + { id: 14, name: 'Night light' }, + { id: 1, name: 'Ocean' }, + { id: 4, name: 'Party' }, + { id: 31, name: 'Pulse' }, + { id: 8, name: 'Pastel colors' }, + { id: 19, name: 'Plantgrowth' }, + { id: 2, name: 'Romance' }, + { id: 16, name: 'Relax' }, + { id: 36, name: 'Snowy sky' }, + { id: 3, name: 'Sunset' }, + { id: 20, name: 'Spring' }, + { id: 21, name: 'Summer' }, + { id: 32, name: 'Steampunk' }, + { id: 17, name: 'True colors' }, + { id: 18, name: 'TV time' }, + { id: 34, name: 'White' }, + { id: 9, name: 'Wake-up' }, + { id: 11, name: 'Warm white' }, + { id: 1000, name: 'Rhythm' }, +]; + +const wizSceneNamesById = new Map(wizScenes.map((sceneArg) => [sceneArg.id, sceneArg.name])); +const wizSceneIdsByName = new Map(wizScenes.map((sceneArg) => [sceneArg.name.toLowerCase(), sceneArg.id])); + +const wizButtonSources: Record = { + wfa1: 'on', + wfa2: 'off', + wfa3: 'night', + wfa8: 'decrease_brightness', + wfa9: 'increase_brightness', + wfa16: '1', + wfa17: '2', + wfa18: '3', + wfa19: '4', +}; + +const pilotPatchKeys = new Set(['state', 'sceneId', 'temp', 'dimming', 'r', 'g', 'b', 'c', 'w', 'speed', 'ratio', 'fanState', 'fanMode', 'fanSpeed', 'fanRevrs']); + +export class WizMapper { + public static readonly sceneNames = wizScenes.map((sceneArg) => sceneArg.name); + + public static toSnapshot(configArg: IWizConfig, connectedArg?: boolean, eventsArg: IWizEvent[] = []): IWizSnapshot { + const source = configArg.snapshot; + const devices: IWizSnapshotDevice[] = [ + ...(source?.devices || []), + ...(configArg.devices || []), + ]; + + for (const entry of configArg.manualEntries || []) { + if (entry.snapshot) { + devices.push(...entry.snapshot.devices); + } else { + devices.push(this.deviceFromManualEntry(entry)); + } + } + + if (!devices.length && (configArg.host || configArg.mac || configArg.name || configArg.deviceInfo || configArg.pilot)) { + devices.push({ + host: configArg.host, + port: configArg.port || wizDefaultPort, + mac: configArg.mac || configArg.deviceInfo?.mac || configArg.pilot?.mac, + name: configArg.name || configArg.deviceInfo?.name, + deviceInfo: configArg.deviceInfo, + pilot: configArg.pilot, + available: connectedArg ?? Boolean(configArg.pilot || source?.connected), + }); + } + + return { + connected: connectedArg ?? source?.connected ?? false, + host: configArg.host || source?.host, + port: configArg.port || source?.port || wizDefaultPort, + devices, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + }; + } + + public static toDevices(snapshotArg: IWizSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + return snapshotArg.devices.map((deviceArg) => this.toDevice(deviceArg, updatedAt)); + } + + public static toEntities(snapshotArg: IWizSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + + for (const device of snapshotArg.devices) { + const info = this.deviceInfo(device); + const pilot = device.pilot || {}; + const features = this.features(device); + const deviceId = this.deviceId(device); + const baseName = this.deviceName(device); + const isSocket = this.isSocket(device); + const hasLight = !isSocket && features.light !== false; + + if (hasLight) { + entities.push(this.entity('light', baseName, deviceId, this.uniqueId('light', device), pilot.state ? 'on' : 'off', usedIds, { + ...this.baseAttributes(device), + brightness: pilot.dimming, + brightness255: this.percentToByte(pilot.dimming), + colorTemperatureKelvin: pilot.temp, + rgbColor: this.rgb(pilot), + sceneId: this.sceneId(pilot), + effect: this.sceneName(pilot), + effectList: this.sceneNames, + writable: true, + }, device.available !== false)); + } + + if (isSocket || features.switch) { + entities.push(this.entity('switch', baseName, deviceId, this.uniqueId('switch', device), pilot.state ? 'on' : 'off', usedIds, { + ...this.baseAttributes(device), + writable: true, + }, device.available !== false)); + } + + if (features.fan || pilot.fanState !== undefined) { + entities.push(this.entity('fan', `${baseName} Fan`, deviceId, this.uniqueId('fan', device), pilot.fanState ? 'on' : 'off', usedIds, { + ...this.baseAttributes(device), + percentage: this.fanPercentage(device, pilot), + fanMode: pilot.fanMode, + fanSpeed: pilot.fanSpeed, + fanReverse: pilot.fanRevrs, + writable: true, + }, device.available !== false)); + } + + if (typeof pilot.rssi === 'number') { + entities.push(this.sensorEntity(device, { key: 'rssi', name: `${baseName} RSSI`, value: pilot.rssi, unit: 'dBm', deviceClass: 'signal_strength' }, usedIds)); + } + if (typeof pilot.pc === 'number' || features.power) { + entities.push(this.sensorEntity(device, { key: 'power', name: `${baseName} Power`, value: typeof pilot.pc === 'number' ? pilot.pc / 1000 : undefined, unit: 'W', deviceClass: 'power' }, usedIds)); + } + if (features.occupancy || pilot.src === 'pir') { + entities.push(this.sensorEntity(device, { key: 'occupancy', name: `${baseName} Occupancy`, platform: 'binary_sensor', value: pilot.src === 'pir' ? pilot.state : undefined, deviceClass: 'occupancy' }, usedIds)); + } + + for (const sensor of device.sensors || []) { + entities.push(this.sensorEntity(device, sensor, usedIds)); + } + + if (features.effect || pilot.speed !== undefined) { + entities.push(this.numberEntity(device, 'effect_speed', `${baseName} Effect Speed`, pilot.speed, usedIds, { min: 10, max: 200, step: 1, wizPilotKey: 'speed' })); + } + if (features.dualHead || pilot.ratio !== undefined) { + entities.push(this.numberEntity(device, 'dual_head_ratio', `${baseName} Dual Head Ratio`, pilot.ratio, usedIds, { min: 0, max: 100, step: 1, wizPilotKey: 'ratio' })); + } + if (features.effect || this.sceneId(pilot) !== undefined) { + entities.push(this.entity('select', `${baseName} Effect`, deviceId, `${this.uniqueId('select', device)}_effect`, this.sceneName(pilot) || 'None', usedIds, { + ...this.baseAttributes(device), + options: this.sceneNames, + wizPilotKey: 'sceneId', + writable: true, + }, device.available !== false)); + } + + for (const button of this.buttons(device)) { + entities.push(this.buttonEntity(device, button, usedIds)); + } + + for (const entity of device.entities || []) { + entities.push(this.explicitEntity(device, entity, usedIds)); + } + } + + return entities; + } + + public static toIntegrationEvent(eventArg: IWizEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'wiz', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static commandForService(snapshotArg: IWizSnapshot, requestArg: IServiceCallRequest): IWizClientCommand | undefined { + if (requestArg.domain === 'wiz' && requestArg.service === 'set_pilot' && this.isRecord(requestArg.data?.payload)) { + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + return { + type: 'setPilot', + service: requestArg.service, + deviceId: targetEntity?.deviceId || requestArg.target.deviceId, + entityId: targetEntity?.id || requestArg.target.entityId, + payload: requestArg.data.payload as IWizPilotPatch, + target: requestArg.target, + }; + } + + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target) { + return undefined; + } + const device = snapshotArg.devices.find((deviceArg) => this.deviceId(deviceArg) === target.deviceId); + const payload = this.payloadForService(target, requestArg, device); + if (!payload || !Object.keys(payload).length) { + return undefined; + } + return { + type: 'setPilot', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + payload, + target: requestArg.target, + }; + } + + public static sceneIdFromName(valueArg: string): number | undefined { + return wizSceneIdsByName.get(valueArg.toLowerCase()); + } + + private static toDevice(deviceArg: IWizSnapshotDevice, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const info = this.deviceInfo(deviceArg); + const pilot = deviceArg.pilot || {}; + const featuresInfo = this.features(deviceArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg }, + ]; + const isSocket = this.isSocket(deviceArg); + const hasLight = !isSocket && featuresInfo.light !== false; + + if (hasLight) { + features.push({ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'on', pilot.state ?? false, updatedAtArg); + if (featuresInfo.brightness !== false || typeof pilot.dimming === 'number') { + features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }); + this.pushDeviceState(state, 'brightness', pilot.dimming, updatedAtArg); + } + if (featuresInfo.colorTemp || typeof pilot.temp === 'number') { + features.push({ id: 'color_temperature', capability: 'light', name: 'Color Temperature', readable: true, writable: true, unit: 'K' }); + this.pushDeviceState(state, 'color_temperature', pilot.temp, updatedAtArg); + } + if (featuresInfo.color || this.rgb(pilot)) { + features.push({ id: 'rgb', capability: 'light', name: 'RGB Color', readable: true, writable: true }); + this.pushDeviceState(state, 'rgb', this.rgbRecord(pilot), updatedAtArg); + } + if (featuresInfo.effect || this.sceneId(pilot) !== undefined) { + features.push({ id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true }); + this.pushDeviceState(state, 'effect', this.sceneName(pilot) || null, updatedAtArg); + } + } + + if (isSocket || featuresInfo.switch) { + features.push({ id: 'switch', capability: 'switch', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'switch', pilot.state ?? false, updatedAtArg); + } + + if (featuresInfo.fan || pilot.fanState !== undefined) { + features.push({ id: 'fan', capability: 'fan', name: 'Fan', readable: true, writable: true }); + this.pushDeviceState(state, 'fan', Boolean(pilot.fanState), updatedAtArg); + features.push({ id: 'fan_speed', capability: 'fan', name: 'Fan Speed', readable: true, writable: true, unit: '%' }); + this.pushDeviceState(state, 'fan_speed', this.fanPercentage(deviceArg, pilot), updatedAtArg); + } + + if (typeof pilot.rssi === 'number') { + features.push({ id: 'rssi', capability: 'sensor', name: 'RSSI', readable: true, writable: false, unit: 'dBm' }); + this.pushDeviceState(state, 'rssi', pilot.rssi, updatedAtArg); + } + if (typeof pilot.pc === 'number' || featuresInfo.power) { + features.push({ id: 'power', capability: 'energy', name: 'Power', readable: true, writable: false, unit: 'W' }); + this.pushDeviceState(state, 'power', typeof pilot.pc === 'number' ? pilot.pc / 1000 : undefined, updatedAtArg); + } + if (featuresInfo.occupancy || pilot.src === 'pir') { + features.push({ id: 'occupancy', capability: 'sensor', name: 'Occupancy', readable: true, writable: false }); + this.pushDeviceState(state, 'occupancy', pilot.src === 'pir' ? Boolean(pilot.state) : undefined, updatedAtArg); + } + + for (const sensor of deviceArg.sensors || []) { + features.push({ id: sensor.key, capability: 'sensor', name: sensor.name || this.title(sensor.key), readable: true, writable: Boolean(sensor.writable), unit: sensor.unit }); + this.pushDeviceState(state, sensor.key, this.deviceStateValue(sensor.value), updatedAtArg); + } + for (const button of this.buttons(deviceArg)) { + features.push({ id: `button_${button.key}`, capability: 'switch', name: button.name || this.title(button.key), readable: true, writable: true }); + this.pushDeviceState(state, `button_${button.key}`, String(button.value ?? button.lastPressedAt ?? 'idle'), updatedAtArg); + } + + return { + id: this.deviceId(deviceArg), + integrationDomain: 'wiz', + name: this.deviceName(deviceArg), + protocol: 'unknown', + manufacturer: info.manufacturer || 'WiZ', + model: info.model || info.moduleName || String(info.bulbType || 'WiZ device'), + online: deviceArg.available !== false, + features, + state, + metadata: { + ...deviceArg.metadata, + protocol: 'wiz-udp-json', + host: deviceArg.host || info.host, + port: deviceArg.port || info.port || wizDefaultPort, + mac: this.mac(deviceArg), + moduleName: info.moduleName, + fwVersion: info.fwVersion, + typeId: info.typeId, + features: featuresInfo, + }, + }; + } + + private static payloadForService(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest, deviceArg?: IWizSnapshotDevice): IWizPilotPatch | undefined { + if (requestArg.service === 'turn_off') { + return entityArg.platform === 'fan' || requestArg.domain === 'fan' ? { fanState: 0 } : { state: false }; + } + if (requestArg.service === 'turn_on') { + const payload: IWizPilotPatch = entityArg.platform === 'fan' || requestArg.domain === 'fan' ? { fanState: 1 } : { state: true }; + this.applyServiceData(payload, requestArg, entityArg, deviceArg); + return payload; + } + if (requestArg.service === 'set_percentage' || requestArg.service === 'set_brightness') { + const percentage = this.percentageFromData(requestArg.data, requestArg.service); + if (percentage === undefined) { + return undefined; + } + if (entityArg.platform === 'fan' || requestArg.domain === 'fan') { + return percentage <= 0 ? { fanState: 0 } : { fanState: 1, fanSpeed: this.percentageToFanSpeed(deviceArg, percentage) }; + } + return percentage <= 0 ? { state: false } : { state: true, dimming: this.clamp(Math.round(percentage), 10, 100) }; + } + if (requestArg.service === 'set_value') { + return this.setValuePayload(entityArg, requestArg); + } + if (requestArg.service === 'select_option' || requestArg.service === 'select_effect') { + const option = this.stringFromData(requestArg.data, ['option', 'effect', 'value']); + const sceneId = typeof option === 'string' ? this.sceneIdFromName(option) : undefined; + return sceneId === undefined ? undefined : { state: true, sceneId }; + } + if (requestArg.domain === 'fan') { + if (requestArg.service === 'set_direction') { + const direction = this.stringFromData(requestArg.data, ['direction']); + return direction ? { fanRevrs: direction === 'reverse' ? 1 : 0 } : undefined; + } + if (requestArg.service === 'set_preset_mode') { + const presetMode = this.stringFromData(requestArg.data, ['preset_mode', 'presetMode']); + return presetMode === 'breeze' ? { fanMode: 2 } : undefined; + } + } + return undefined; + } + + private static applyServiceData(payloadArg: IWizPilotPatch, requestArg: IServiceCallRequest, entityArg: IIntegrationEntity, deviceArg?: IWizSnapshotDevice): void { + const brightness = this.percentageFromData(requestArg.data, 'turn_on'); + if (brightness !== undefined && entityArg.platform !== 'fan' && requestArg.domain !== 'fan') { + payloadArg.dimming = this.clamp(Math.round(brightness), 10, 100); + } + if (brightness !== undefined && (entityArg.platform === 'fan' || requestArg.domain === 'fan')) { + payloadArg.fanSpeed = this.percentageToFanSpeed(deviceArg, brightness); + } + const kelvin = this.kelvinFromData(requestArg.data); + if (kelvin !== undefined) { + payloadArg.temp = kelvin; + } + const rgb = this.rgbFromData(requestArg.data, 'rgb_color') || this.rgbFromData(requestArg.data, 'rgb'); + if (rgb) { + payloadArg.r = rgb[0]; + payloadArg.g = rgb[1]; + payloadArg.b = rgb[2]; + } + const rgbw = this.rgbFromData(requestArg.data, 'rgbw_color'); + if (rgbw) { + payloadArg.r = rgbw[0]; + payloadArg.g = rgbw[1]; + payloadArg.b = rgbw[2]; + payloadArg.w = rgbw[3]; + } + const rgbww = this.rgbFromData(requestArg.data, 'rgbww_color'); + if (rgbww) { + payloadArg.r = rgbww[0]; + payloadArg.g = rgbww[1]; + payloadArg.b = rgbww[2]; + payloadArg.c = rgbww[3]; + payloadArg.w = rgbww[4]; + } + const effect = this.stringFromData(requestArg.data, ['effect', 'option']); + const sceneId = effect ? this.sceneIdFromName(effect) : this.numberFromData(requestArg.data, ['sceneId', 'scene_id']); + if (sceneId !== undefined) { + payloadArg.sceneId = sceneId; + } + const speed = this.numberFromData(requestArg.data, ['speed']); + if (speed !== undefined) { + payloadArg.speed = this.clamp(Math.round(speed), 10, 200); + } + } + + private static setValuePayload(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): IWizPilotPatch | undefined { + const value = this.valueFromData(requestArg.data, ['value', 'state']); + const field = this.stringFromData(requestArg.data, ['field', 'attribute', 'key']) || (typeof entityArg.attributes?.wizPilotKey === 'string' ? entityArg.attributes.wizPilotKey : undefined); + if (value === undefined || !field || !pilotPatchKeys.has(field)) { + return undefined; + } + if (field === 'speed') { + return typeof value === 'number' ? { speed: this.clamp(Math.round(value), 10, 200) } : undefined; + } + if (field === 'ratio') { + return typeof value === 'number' ? { ratio: this.clamp(Math.round(value), 0, 100) } : undefined; + } + if (field === 'sceneId' && typeof value === 'string') { + const sceneId = this.sceneIdFromName(value); + return sceneId === undefined ? undefined : { state: true, sceneId }; + } + return { [field]: value } as IWizPilotPatch; + } + + private static findTargetEntity(snapshotArg: IWizSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && Boolean(entityArg.attributes?.writable)) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId); + } + return entities.find((entityArg) => entityArg.platform === requestArg.domain && Boolean(entityArg.attributes?.writable)); + } + + private static deviceFromManualEntry(entryArg: IWizManualEntry): IWizSnapshotDevice { + return { + id: entryArg.id, + host: entryArg.host, + port: entryArg.port || wizDefaultPort, + mac: entryArg.mac || entryArg.macAddress || entryArg.deviceInfo?.mac, + name: entryArg.name || entryArg.deviceInfo?.name, + deviceInfo: { + ...entryArg.deviceInfo, + host: entryArg.host, + port: entryArg.port || wizDefaultPort, + mac: entryArg.mac || entryArg.macAddress || entryArg.deviceInfo?.mac, + name: entryArg.name || entryArg.deviceInfo?.name, + manufacturer: entryArg.manufacturer || entryArg.deviceInfo?.manufacturer, + model: entryArg.model || entryArg.deviceInfo?.model, + }, + pilot: entryArg.pilot, + available: Boolean(entryArg.pilot), + metadata: entryArg.metadata, + }; + } + + private static explicitEntity(deviceArg: IWizSnapshotDevice, entityArg: IWizEntityRecord, usedIdsArg: Map): IIntegrationEntity { + const platform = this.corePlatform(entityArg.platform); + const name = entityArg.name || this.deviceName(deviceArg); + return this.entity(platform, name, this.deviceId(deviceArg), entityArg.uniqueId || `${this.uniqueId(platform, deviceArg)}_${entityArg.key || this.slug(name)}`, this.entityState(entityArg.state, platform), usedIdsArg, { + ...this.baseAttributes(deviceArg), + ...entityArg.attributes, + wizPilotKey: entityArg.key, + writable: entityArg.writable, + }, entityArg.available !== false && deviceArg.available !== false, entityArg.entityId); + } + + private static sensorEntity(deviceArg: IWizSnapshotDevice, sensorArg: IWizSensorRecord, usedIdsArg: Map): IIntegrationEntity { + const platform = sensorArg.platform || 'sensor'; + const state = platform === 'binary_sensor' ? sensorArg.value ? 'on' : 'off' : sensorArg.value ?? 'unknown'; + return this.entity(platform, sensorArg.name || `${this.deviceName(deviceArg)} ${this.title(sensorArg.key)}`, this.deviceId(deviceArg), `${this.uniqueId(platform, deviceArg)}_${this.slug(sensorArg.key)}`, state, usedIdsArg, { + ...this.baseAttributes(deviceArg), + wizSensorKey: sensorArg.key, + wizPilotKey: sensorArg.writable ? sensorArg.key : undefined, + deviceClass: sensorArg.deviceClass, + unit: sensorArg.unit, + writable: sensorArg.writable, + }, sensorArg.available !== false && deviceArg.available !== false); + } + + private static numberEntity(deviceArg: IWizSnapshotDevice, keyArg: string, nameArg: string, valueArg: unknown, usedIdsArg: Map, attributesArg: Record): IIntegrationEntity { + return this.entity('number', nameArg, this.deviceId(deviceArg), `${this.uniqueId('number', deviceArg)}_${this.slug(keyArg)}`, valueArg ?? 'unknown', usedIdsArg, { + ...this.baseAttributes(deviceArg), + ...attributesArg, + writable: true, + }, deviceArg.available !== false); + } + + private static buttonEntity(deviceArg: IWizSnapshotDevice, buttonArg: IWizButtonRecord, usedIdsArg: Map): IIntegrationEntity { + const name = buttonArg.name || `${this.deviceName(deviceArg)} ${this.title(buttonArg.key)}`; + return this.entity('button', name, this.deviceId(deviceArg), `${this.uniqueId('button', deviceArg)}_${this.slug(buttonArg.key)}`, buttonArg.value ?? 'idle', usedIdsArg, { + ...this.baseAttributes(deviceArg), + wizButtonKey: buttonArg.key, + lastPressedAt: buttonArg.lastPressedAt, + writable: true, + }, buttonArg.available !== false && deviceArg.available !== false); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record, availableArg: boolean, explicitEntityIdArg?: string): IIntegrationEntity { + return { + id: explicitEntityIdArg || this.entityId(platformArg, nameArg, usedIdsArg), + uniqueId: uniqueIdArg, + integrationDomain: 'wiz', + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: attributesArg, + available: availableArg, + }; + } + + private static buttons(deviceArg: IWizSnapshotDevice): IWizButtonRecord[] { + const buttons = [...(deviceArg.buttons || [])]; + const source = typeof deviceArg.pilot?.src === 'string' ? deviceArg.pilot.src : undefined; + if (source && wizButtonSources[source]) { + buttons.push({ key: wizButtonSources[source], name: `${this.deviceName(deviceArg)} Button ${this.title(wizButtonSources[source])}`, value: source }); + } + return buttons; + } + + private static deviceInfo(deviceArg: IWizSnapshotDevice): IWizDeviceInfo { + return { + ...deviceArg.deviceInfo, + id: deviceArg.deviceInfo?.id || deviceArg.id, + host: deviceArg.host || deviceArg.deviceInfo?.host, + port: deviceArg.port || deviceArg.deviceInfo?.port || wizDefaultPort, + mac: deviceArg.mac || deviceArg.deviceInfo?.mac || deviceArg.pilot?.mac, + name: deviceArg.name || deviceArg.deviceInfo?.name, + }; + } + + private static features(deviceArg: IWizSnapshotDevice): IWizDeviceFeatures { + const info = this.deviceInfo(deviceArg); + const pilot = deviceArg.pilot || {}; + const lowerType = String(info.bulbType || info.model || info.moduleName || '').toLowerCase(); + const isSocket = this.isSocket(deviceArg); + return { + light: !isSocket, + switch: isSocket, + brightness: typeof pilot.dimming === 'number' || !isSocket, + color: ['r', 'g', 'b'].every((keyArg) => typeof pilot[keyArg] === 'number') || lowerType.includes('rgb'), + colorTemp: typeof pilot.temp === 'number' || lowerType.includes('tw') || lowerType.includes('tunable'), + effect: typeof pilot.sceneId === 'number' || typeof pilot.schdPsetId === 'number' || info.features?.effect, + fan: typeof pilot.fanState === 'number' || info.features?.fan, + power: typeof pilot.pc === 'number' || info.powerMonitoring || info.features?.power, + occupancy: pilot.src === 'pir' || info.features?.occupancy, + button: Boolean(deviceArg.buttons?.length) || Boolean(pilot.src && wizButtonSources[String(pilot.src)]) || info.features?.button, + dualHead: typeof pilot.ratio === 'number' || info.features?.dualHead, + ...info.features, + }; + } + + private static isSocket(deviceArg: IWizSnapshotDevice): boolean { + const info = this.deviceInfo(deviceArg); + const text = [info.bulbType, info.model, info.moduleName, info.typeId].filter((valueArg) => valueArg !== undefined).join(' ').toLowerCase(); + return info.isSocket === true || info.features?.switch === true || text.includes('socket') || text.includes('plug'); + } + + private static deviceId(deviceArg: IWizSnapshotDevice): string { + return `wiz.device.${this.slug(deviceArg.id || this.mac(deviceArg) || deviceArg.host || this.deviceName(deviceArg))}`; + } + + private static uniqueId(platformArg: string, deviceArg: IWizSnapshotDevice): string { + return `wiz_${platformArg}_${this.slug(this.mac(deviceArg) || deviceArg.id || deviceArg.host || this.deviceName(deviceArg))}`; + } + + private static deviceName(deviceArg: IWizSnapshotDevice): string { + const info = this.deviceInfo(deviceArg); + return info.name || deviceArg.name || (this.mac(deviceArg) ? `WiZ ${this.shortMac(this.mac(deviceArg))}` : 'WiZ device'); + } + + private static mac(deviceArg: IWizSnapshotDevice): string | undefined { + return this.normalizeMac(deviceArg.mac || deviceArg.deviceInfo?.mac || deviceArg.pilot?.mac); + } + + private static baseAttributes(deviceArg: IWizSnapshotDevice): Record { + const info = this.deviceInfo(deviceArg); + return { + wizDeviceId: this.deviceId(deviceArg), + wizHost: info.host, + wizPort: info.port || wizDefaultPort, + wizMac: this.mac(deviceArg), + moduleName: info.moduleName, + fwVersion: info.fwVersion, + model: info.model, + }; + } + + private static sceneId(pilotArg: IWizPilotState): number | undefined { + if (typeof pilotArg.schdPsetId === 'number') { + return 1000; + } + return typeof pilotArg.sceneId === 'number' && pilotArg.sceneId > 0 ? pilotArg.sceneId : undefined; + } + + private static sceneName(pilotArg: IWizPilotState): string | undefined { + const sceneId = this.sceneId(pilotArg); + return sceneId === undefined ? undefined : wizSceneNamesById.get(sceneId) || `Scene ${sceneId}`; + } + + private static fanPercentage(deviceArg: IWizSnapshotDevice, pilotArg: IWizPilotState): number | undefined { + if (typeof pilotArg.fanSpeed !== 'number') { + return undefined; + } + const max = this.deviceInfo(deviceArg).fanSpeedRange || 6; + return this.clamp(Math.round(pilotArg.fanSpeed / max * 100), 0, 100); + } + + private static percentageToFanSpeed(deviceArg: IWizSnapshotDevice | undefined, percentageArg: number): number { + const max = deviceArg ? this.deviceInfo(deviceArg).fanSpeedRange || 6 : 6; + return this.clamp(Math.ceil(this.clamp(percentageArg, 0, 100) / 100 * max), 1, max); + } + + private static percentToByte(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' ? this.clamp(Math.round(valueArg / 100 * 255), 0, 255) : undefined; + } + + private static percentageFromData(dataArg: Record | undefined, serviceArg: string): number | undefined { + const pct = this.numberFromData(dataArg, ['percentage', 'brightness_pct']); + if (pct !== undefined) { + return this.clamp(pct, 0, 100); + } + const brightness = this.numberFromData(dataArg, serviceArg === 'set_brightness' ? ['brightness', 'value'] : ['brightness']); + return brightness === undefined ? undefined : this.clamp(Math.round(brightness / 255 * 100), 0, 100); + } + + private static kelvinFromData(dataArg: Record | undefined): number | undefined { + const direct = this.numberFromData(dataArg, ['color_temp_kelvin', 'kelvin', 'temp', 'color_temperature']); + if (direct !== undefined) { + return Math.round(direct); + } + const mired = this.numberFromData(dataArg, ['color_temp', 'color_temp_mired']); + return mired && mired > 0 ? Math.round(1000000 / mired) : undefined; + } + + private static rgb(pilotArg: IWizPilotState): number[] | undefined { + return typeof pilotArg.r === 'number' && typeof pilotArg.g === 'number' && typeof pilotArg.b === 'number' + ? [pilotArg.r, pilotArg.g, pilotArg.b] + : undefined; + } + + private static rgbRecord(pilotArg: IWizPilotState): Record | undefined { + const rgb = this.rgb(pilotArg); + return rgb ? { r: rgb[0], g: rgb[1], b: rgb[2], c: pilotArg.c, w: pilotArg.w } : undefined; + } + + private static rgbFromData(dataArg: Record | undefined, keyArg: string): number[] | undefined { + const value = dataArg?.[keyArg]; + if (!Array.isArray(value) || value.length < 3) { + return undefined; + } + const numbers = value.map((valueArg) => typeof valueArg === 'number' && Number.isFinite(valueArg) ? this.clamp(Math.round(valueArg), 0, 255) : undefined); + return numbers.every((valueArg) => valueArg !== undefined) ? numbers as number[] : undefined; + } + + private static valueFromData(dataArg: Record | undefined, keysArg: string[]): unknown { + for (const key of keysArg) { + if (dataArg && key in dataArg) { + return dataArg[key]; + } + } + return undefined; + } + + private static numberFromData(dataArg: Record | undefined, keysArg: string[]): number | undefined { + const value = this.valueFromData(dataArg, keysArg); + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + private static stringFromData(dataArg: Record | undefined, keysArg: string[]): string | undefined { + const value = this.valueFromData(dataArg, keysArg); + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static corePlatform(platformArg: TEntityPlatform | string): TEntityPlatform { + const platform = platformArg.toLowerCase(); + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + private static entityState(valueArg: unknown, platformArg: TEntityPlatform): unknown { + if (platformArg === 'light' || platformArg === 'switch' || platformArg === 'fan' || platformArg === 'binary_sensor') { + return typeof valueArg === 'boolean' ? valueArg ? 'on' : 'off' : valueArg ?? 'unknown'; + } + return valueArg ?? 'unknown'; + } + + private static pushDeviceState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void { + if (valueArg === undefined) { + return; + } + stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg)}`; + const count = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, count + 1); + return count ? `${base}_${count + 1}` : base; + } + + private static shortMac(valueArg?: string): string { + return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase(); + } + + private static normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'wiz'; + } + + private static clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/wiz/wiz.types.ts b/ts/integrations/wiz/wiz.types.ts index b492c03..5d09aa9 100644 --- a/ts/integrations/wiz/wiz.types.ts +++ b/ts/integrations/wiz/wiz.types.ts @@ -1,4 +1,265 @@ -export interface IHomeAssistantWizConfig { - // TODO: replace with the TypeScript-native config for wiz. +import type { IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; + +export const wizDefaultPort = 38899; + +export type TWizBulbType = 'rgb' | 'tw' | 'dw' | 'socket' | 'fan' | 'unknown' | string; +export type TWizDiscoveryProtocol = 'mdns' | 'udp' | 'manual'; + +export interface IWizConfig { + host?: string; + port?: number; + mac?: string; + name?: string; + deviceInfo?: IWizDeviceInfo; + pilot?: IWizPilotState; + snapshot?: IWizSnapshot; + devices?: IWizSnapshotDevice[]; + manualEntries?: IWizManualEntry[]; + events?: IWizEvent[]; + timeoutMs?: number; + commandExecutor?: TWizCommandExecutor; + [key: string]: unknown; +} + +export interface IHomeAssistantWizConfig extends IWizConfig {} + +export interface IWizDeviceFeatures { + light?: boolean; + switch?: boolean; + brightness?: boolean; + color?: boolean; + colorTemp?: boolean; + effect?: boolean; + fan?: boolean; + fanReverse?: boolean; + fanBreezeMode?: boolean; + power?: boolean; + occupancy?: boolean; + button?: boolean; + dualHead?: boolean; + [key: string]: unknown; +} + +export interface IWizKelvinRange { + min?: number; + max?: number; +} + +export interface IWizDeviceInfo { + id?: string; + mac?: string; + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + moduleName?: string; + fwVersion?: string; + typeId?: number | string; + bulbType?: TWizBulbType; + isSocket?: boolean; + whiteChannels?: number; + kelvinRange?: IWizKelvinRange; + fanSpeedRange?: number; + powerMonitoring?: boolean; + features?: IWizDeviceFeatures; + [key: string]: unknown; +} + +export interface IWizPilotState { + mac?: string; + rssi?: number; + src?: string; + state?: boolean; + sceneId?: number; + schdPsetId?: number; + temp?: number; + dimming?: number; + r?: number; + g?: number; + b?: number; + c?: number; + w?: number; + speed?: number; + ratio?: number; + pc?: number; + fanState?: number; + fanMode?: number; + fanSpeed?: number; + fanRevrs?: number; + [key: string]: unknown; +} + +export interface IWizPilotPatch { + state?: boolean; + sceneId?: number; + temp?: number; + dimming?: number; + r?: number; + g?: number; + b?: number; + c?: number; + w?: number; + speed?: number; + ratio?: number; + fanState?: number; + fanMode?: number; + fanSpeed?: number; + fanRevrs?: number; + [key: string]: unknown; +} + +export interface IWizSensorRecord { + key: string; + name?: string; + platform?: 'sensor' | 'binary_sensor'; + value?: unknown; + unit?: string; + deviceClass?: string; + writable?: boolean; + available?: boolean; + [key: string]: unknown; +} + +export interface IWizButtonRecord { + key: string; + name?: string; + value?: unknown; + lastPressedAt?: string | number; + available?: boolean; + [key: string]: unknown; +} + +export interface IWizEntityRecord { + platform: TEntityPlatform; + key?: string; + entityId?: string; + uniqueId?: string; + name?: string; + state?: unknown; + attributes?: Record; + available?: boolean; + writable?: boolean; + [key: string]: unknown; +} + +export interface IWizSnapshotDevice { + id?: string; + host?: string; + port?: number; + mac?: string; + name?: string; + available?: boolean; + deviceInfo?: IWizDeviceInfo; + pilot?: IWizPilotState; + sensors?: IWizSensorRecord[]; + buttons?: IWizButtonRecord[]; + entities?: IWizEntityRecord[]; + metadata?: Record; + [key: string]: unknown; +} + +export interface IWizSnapshot { + connected: boolean; + host?: string; + port?: number; + devices: IWizSnapshotDevice[]; + events: IWizEvent[]; +} + +export interface IWizEvent { + type: 'pilot' | 'command_mapped' | 'command_failed' | 'udp_response' | 'error' | string; + timestamp?: number; + deviceId?: string; + entityId?: string; + command?: IWizClientCommand; + data?: unknown; + [key: string]: unknown; +} + +export interface IWizClientCommand { + type: string; + service: string; + deviceId?: string; + entityId?: string; + payload: IWizPilotPatch; + target?: IServiceCallRequest['target']; + [key: string]: unknown; +} + +export interface IWizCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export type TWizCommandExecutor = ( + commandArg: IWizClientCommand +) => Promise | IWizCommandResult | unknown; + +export interface IWizUdpCommand = Record> { + method: string; + params?: TParams; + id?: number | string; + env?: string; +} + +export interface IWizUdpError { + code?: number; + message?: string; + data?: unknown; +} + +export interface IWizUdpResponse { + method?: string; + env?: string; + id?: number | string; + result?: TResult; + params?: TResult; + error?: IWizUdpError; + [key: string]: unknown; +} + +export interface IWizMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; + [key: string]: unknown; +} + +export interface IWizUdpDiscoveryRecord { + host?: string; + address?: string; + ip?: string; + ip_address?: string; + port?: number; + mac?: string; + mac_address?: string; + name?: string; + method?: string; + result?: Record; + response?: IWizUdpResponse>; + metadata?: Record; + [key: string]: unknown; +} + +export interface IWizManualEntry { + host?: string; + port?: number; + id?: string; + mac?: string; + macAddress?: string; + name?: string; + manufacturer?: string; + model?: string; + deviceInfo?: IWizDeviceInfo; + pilot?: IWizPilotState; + snapshot?: IWizSnapshot; + metadata?: Record; [key: string]: unknown; } diff --git a/ts/integrations/xiaomi_miio/.generated-by-smarthome-exchange b/ts/integrations/xiaomi_miio/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/xiaomi_miio/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/xiaomi_miio/index.ts b/ts/integrations/xiaomi_miio/index.ts index b9e3f7f..768ac35 100644 --- a/ts/integrations/xiaomi_miio/index.ts +++ b/ts/integrations/xiaomi_miio/index.ts @@ -1,2 +1,6 @@ export * from './xiaomi_miio.classes.integration.js'; +export * from './xiaomi_miio.classes.client.js'; +export * from './xiaomi_miio.classes.configflow.js'; +export * from './xiaomi_miio.discovery.js'; +export * from './xiaomi_miio.mapper.js'; export * from './xiaomi_miio.types.js'; diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.classes.client.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.client.ts new file mode 100644 index 0000000..2b5f7d4 --- /dev/null +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.client.ts @@ -0,0 +1,65 @@ +import type { IXiaomiMiioClientCommand, IXiaomiMiioCommandResult, IXiaomiMiioConfig, IXiaomiMiioEvent, IXiaomiMiioSnapshot } from './xiaomi_miio.types.js'; +import { XiaomiMiioMapper } from './xiaomi_miio.mapper.js'; + +type TXiaomiMiioEventHandler = (eventArg: IXiaomiMiioEvent) => void; + +export class XiaomiMiioClient { + private readonly events: IXiaomiMiioEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IXiaomiMiioConfig) {} + + public async getSnapshot(): Promise { + return XiaomiMiioMapper.toSnapshot(this.config, undefined, this.events); + } + + public onEvent(handlerArg: TXiaomiMiioEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IXiaomiMiioClientCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, uniqueId: commandArg.uniqueId, timestamp: Date.now() }); + if (this.config.commandExecutor) { + const result = await this.config.commandExecutor(commandArg); + return this.commandResult(result, commandArg); + } + return { + success: false, + error: `Xiaomi Miio live UDP writes for ${this.config.host || 'configured host'} require encrypted miIO packet framing and validated token handling. This dependency-free TypeScript port maps commands but does not send encrypted local writes without a commandExecutor.`, + data: { command: commandArg }, + }; + } + + public async sendLocalCommand(methodArg: string, paramsArg: unknown[] = []): Promise { + return this.sendCommand({ + type: 'local.miio.command', + service: 'raw_command', + method: methodArg, + params: paramsArg, + payload: { method: methodArg, params: paramsArg }, + }); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private emit(eventArg: IXiaomiMiioEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IXiaomiMiioClientCommand): IXiaomiMiioCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IXiaomiMiioCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } +} diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.classes.configflow.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.configflow.ts new file mode 100644 index 0000000..6025b33 --- /dev/null +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.configflow.ts @@ -0,0 +1,103 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { normalizeXiaomiMiioToken } from './xiaomi_miio.discovery.js'; +import type { IXiaomiMiioConfig, IXiaomiMiioSnapshot } from './xiaomi_miio.types.js'; + +const defaultMiioPort = 54321; + +export class XiaomiMiioConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaults = this.defaultsFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Connect Xiaomi Home device', + description: 'Provide the local host, 32-character Miio token, model/name, and optional snapshot JSON. Live encrypted UDP probing is not performed by this TypeScript port.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'token', label: 'Miio token', type: 'password', required: true }, + { name: 'model', label: 'Model', type: 'text', required: true }, + { name: 'name', label: 'Name', type: 'text' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || defaults.host; + const token = normalizeXiaomiMiioToken(valuesArg.token) || defaults.token; + const model = this.stringValue(valuesArg.model) || defaults.model; + const name = this.stringValue(valuesArg.name) || defaults.name || model; + const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot); + + if (!host) { + return { kind: 'error', title: 'Invalid Xiaomi Miio host', error: 'Host is required.' }; + } + if (!token) { + return { kind: 'error', title: 'Invalid Xiaomi Miio token', error: 'Token must be 32 hexadecimal characters.' }; + } + if (!model) { + return { kind: 'error', title: 'Invalid Xiaomi Miio model', error: 'Model is required.' }; + } + if (snapshot === false) { + return { kind: 'error', title: 'Invalid Xiaomi Miio snapshot', error: 'Snapshot JSON must be a JSON object.' }; + } + + return { + kind: 'done', + title: 'Xiaomi Home device configured', + config: { + host, + port: defaults.port || defaultMiioPort, + token, + model, + name: name || model, + macAddress: defaults.macAddress, + deviceId: defaults.deviceId, + snapshot: snapshot || undefined, + }, + }; + }, + }; + } + + private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { host?: string; port?: number; token?: string; model?: string; name?: string; macAddress?: string; deviceId?: string; snapshot?: IXiaomiMiioSnapshot } { + const metadata = candidateArg.metadata || {}; + return { + host: candidateArg.host, + port: candidateArg.port, + token: normalizeXiaomiMiioToken(metadata.token), + model: candidateArg.model || this.stringValue(metadata.model), + name: candidateArg.name || this.stringValue(metadata.name), + macAddress: candidateArg.macAddress || this.stringValue(metadata.macAddress), + deviceId: candidateArg.id || this.stringValue(metadata.deviceId), + snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined, + }; + } + + private snapshotValue(valueArg: unknown, fallbackArg?: IXiaomiMiioSnapshot): IXiaomiMiioSnapshot | undefined | false { + if (valueArg === undefined || valueArg === null || valueArg === '') { + return fallbackArg; + } + if (this.isSnapshot(valueArg)) { + return valueArg; + } + if (typeof valueArg !== 'string') { + return false; + } + try { + const parsed = JSON.parse(valueArg) as unknown; + return this.isRecord(parsed) ? parsed as unknown as IXiaomiMiioSnapshot : false; + } catch { + return false; + } + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private isSnapshot(valueArg: unknown): valueArg is IXiaomiMiioSnapshot { + return this.isRecord(valueArg); + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.classes.integration.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.integration.ts index ea745dd..cb0df5c 100644 --- a/ts/integrations/xiaomi_miio/xiaomi_miio.classes.integration.ts +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.classes.integration.ts @@ -1,30 +1,77 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { XiaomiMiioClient } from './xiaomi_miio.classes.client.js'; +import { XiaomiMiioConfigFlow } from './xiaomi_miio.classes.configflow.js'; +import { createXiaomiMiioDiscoveryDescriptor } from './xiaomi_miio.discovery.js'; +import { XiaomiMiioMapper } from './xiaomi_miio.mapper.js'; +import type { IXiaomiMiioConfig } from './xiaomi_miio.types.js'; -export class HomeAssistantXiaomiMiioIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "xiaomi_miio", - displayName: "Xiaomi Home", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/xiaomi_miio", - "upstreamDomain": "xiaomi_miio", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "construct==2.10.68", - "micloud==0.5", - "python-miio==0.5.12" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@rytilahti", - "@syssi", - "@starkillerOG" - ] -}, +export class XiaomiMiioIntegration extends BaseIntegration { + public readonly domain = 'xiaomi_miio'; + public readonly displayName = 'Xiaomi Home'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createXiaomiMiioDiscoveryDescriptor(); + public readonly configFlow = new XiaomiMiioConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/xiaomi_miio', + upstreamDomain: 'xiaomi_miio', + documentation: 'https://www.home-assistant.io/integrations/xiaomi_miio', + integrationType: 'hub', + iotClass: 'local_polling', + requirements: ['construct==2.10.68', 'micloud==0.5', 'python-miio==0.5.12'], + zeroconf: ['_miio._udp.local.'], + codeowners: ['@rytilahti', '@syssi', '@starkillerOG'], + }; + + public async setup(configArg: IXiaomiMiioConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new XiaomiMiioRuntime(new XiaomiMiioClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantXiaomiMiioIntegration extends XiaomiMiioIntegration {} + +class XiaomiMiioRuntime implements IIntegrationRuntime { + public domain = 'xiaomi_miio'; + + constructor(private readonly client: XiaomiMiioClient) {} + + public async devices(): Promise { + return XiaomiMiioMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return XiaomiMiioMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.type === 'availability_changed' ? 'availability_changed' : eventArg.type === 'device_removed' ? 'device_removed' : 'state_changed', + integrationDomain: 'xiaomi_miio', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = XiaomiMiioMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported Xiaomi Miio service: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.discovery.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.discovery.ts new file mode 100644 index 0000000..00dd24d --- /dev/null +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.discovery.ts @@ -0,0 +1,217 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IXiaomiMiioDhcpRecord, IXiaomiMiioManualEntry, IXiaomiMiioMdnsRecord } from './xiaomi_miio.types.js'; + +const defaultMiioPort = 54321; +const miioMdnsType = '_miio._udp.local'; +const xiaomiTextHints = ['xiaomi', 'miio', 'miot', 'mijia', 'roborock', 'rockrobo', 'zhimi', 'chuangmi', 'lumi', 'philips.light', 'viomi', 'dreame', 'deerma', 'dmaker']; + +export const normalizeXiaomiMiioToken = (valueArg: unknown): string | undefined => { + if (typeof valueArg !== 'string') { + return undefined; + } + const token = valueArg.trim().toLowerCase(); + return /^[0-9a-f]{32}$/.test(token) ? token : undefined; +}; + +export class XiaomiMiioMdnsMatcher implements IDiscoveryMatcher { + public id = 'xiaomi-miio-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Xiaomi Miio mDNS records advertised as _miio._udp.local.'; + + public async matches(recordArg: IXiaomiMiioMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type || recordArg.serviceType); + const txt = recordArg.txt || recordArg.properties || {}; + const name = recordArg.name || recordArg.hostname || ''; + const model = this.txt(txt, 'model') || this.modelFromName(name); + const macAddress = this.formatMac(this.txt(txt, 'mac') || this.macFromPoch(this.txt(txt, 'poch'))); + const did = this.txt(txt, 'did') || this.txt(txt, 'id'); + const matched = type === miioMdnsType || Boolean(model && name.toLowerCase().includes('miio')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Xiaomi Miio advertisement.' }; + } + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const id = macAddress || did || host || name; + return { + matched: true, + confidence: host && (macAddress || model) ? 'certain' : host ? 'high' : 'medium', + reason: 'mDNS record matches Xiaomi Miio zeroconf metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'xiaomi_miio', + id, + host, + port: recordArg.port || defaultMiioPort, + name: model ? `${model} ${host || ''}`.trim() : name, + manufacturer: 'Xiaomi', + model, + macAddress, + metadata: { + xiaomiMiio: true, + mdnsName: recordArg.name, + mdnsType: recordArg.type || recordArg.serviceType, + txt, + did, + model, + macAddress, + }, + }, + metadata: { model, macAddress, did }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private macFromPoch(valueArg?: string): string | undefined { + const match = /mac=([0-9a-f:]+)/i.exec(valueArg || ''); + return match?.[1]; + } + + private modelFromName(valueArg: string): string | undefined { + const raw = valueArg.split('._')[0].replace(/_miio.*$/i, '').replace(/\.local\.?$/i, '').trim(); + if (!raw || raw.startsWith('_')) { + return undefined; + } + return raw.replace(/-/g, '.'); + } + + private formatMac(valueArg?: string): string | undefined { + const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (compact.length !== 12) { + return undefined; + } + return compact.match(/.{1,2}/g)?.join(':'); + } +} + +export class XiaomiMiioDhcpMatcher implements IDiscoveryMatcher { + public id = 'xiaomi-miio-dhcp-match'; + public source = 'dhcp' as const; + public description = 'Recognize Xiaomi Miio devices from DHCP hostname, manufacturer, model, or metadata hints.'; + + public async matches(recordArg: IXiaomiMiioDhcpRecord): Promise { + const host = recordArg.host || recordArg.ipAddress || recordArg.address; + const hostname = recordArg.hostname || recordArg.hostName; + const metadata = recordArg.metadata || {}; + const text = [hostname, recordArg.manufacturer, recordArg.model, recordArg.vendorClassIdentifier, metadata.model, metadata.manufacturer] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const token = normalizeXiaomiMiioToken(metadata.token); + const matched = Boolean(recordArg.integrationDomain === 'xiaomi_miio' || metadata.xiaomiMiio || token || xiaomiTextHints.some((hintArg) => text.includes(hintArg))); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'DHCP record does not contain Xiaomi Miio metadata.' }; + } + const macAddress = this.formatMac(recordArg.macAddress || recordArg.mac || this.stringValue(metadata.macAddress)); + const id = macAddress || recordArg.deviceId as string | undefined || hostname || host; + return { + matched: true, + confidence: host && token ? 'certain' : host && macAddress ? 'high' : host ? 'medium' : 'low', + reason: 'DHCP record contains Xiaomi Miio host or vendor metadata.', + normalizedDeviceId: id, + candidate: { + source: 'dhcp', + integrationDomain: 'xiaomi_miio', + id, + host, + port: defaultMiioPort, + name: hostname || recordArg.model || 'Xiaomi Miio device', + manufacturer: recordArg.manufacturer || 'Xiaomi', + model: recordArg.model || this.stringValue(metadata.model), + macAddress, + metadata: { ...metadata, xiaomiMiio: true, token, macAddress }, + }, + metadata: { tokenConfigured: Boolean(token), macAddress }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; + } + + private formatMac(valueArg?: string): string | undefined { + const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (compact.length !== 12) { + return undefined; + } + return compact.match(/.{1,2}/g)?.join(':'); + } +} + +export class XiaomiMiioManualMatcher implements IDiscoveryMatcher { + public id = 'xiaomi-miio-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Xiaomi Miio host and token setup entries.'; + + public async matches(inputArg: IXiaomiMiioManualEntry): Promise { + const host = inputArg.host; + const token = normalizeXiaomiMiioToken(inputArg.token || inputArg.metadata?.token); + if (!host || !token) { + return { matched: false, confidence: 'low', reason: 'Manual Xiaomi Miio setup requires host and a 32-character hexadecimal token.' }; + } + const id = inputArg.deviceId || inputArg.id || inputArg.macAddress || host; + return { + matched: true, + confidence: inputArg.model ? 'certain' : 'high', + reason: 'Manual entry contains Xiaomi Miio host and token.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'xiaomi_miio', + id, + host, + port: inputArg.port || defaultMiioPort, + name: inputArg.name || inputArg.model || 'Xiaomi Miio device', + manufacturer: 'Xiaomi', + model: inputArg.model, + macAddress: inputArg.macAddress, + metadata: { ...inputArg.metadata, xiaomiMiio: true, token, snapshot: inputArg.snapshot }, + }, + metadata: { tokenConfigured: true }, + }; + } +} + +export class XiaomiMiioCandidateValidator implements IDiscoveryValidator { + public id = 'xiaomi-miio-candidate-validator'; + public description = 'Validate Xiaomi Miio discovery candidates and manual token shape.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const token = normalizeXiaomiMiioToken(metadata.token); + if (candidateArg.source === 'manual' && (!candidateArg.host || !token)) { + return { matched: false, confidence: 'low', reason: 'Manual Xiaomi Miio candidates require host and a valid 32-character hexadecimal token.' }; + } + if (metadata.token !== undefined && !token) { + return { matched: false, confidence: 'low', reason: 'Xiaomi Miio token metadata is present but invalid.' }; + } + const text = [candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.model, metadata.manufacturer] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const matched = Boolean(candidateArg.integrationDomain === 'xiaomi_miio' || metadata.xiaomiMiio || token || xiaomiTextHints.some((hintArg) => text.includes(hintArg))); + return { + matched, + confidence: matched && candidateArg.host && token ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Xiaomi Miio metadata.' : 'Candidate is not Xiaomi Miio.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host, + metadata: matched ? { tokenConfigured: Boolean(token), host: candidateArg.host } : undefined, + }; + } +} + +export const createXiaomiMiioDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'xiaomi_miio', displayName: 'Xiaomi Home' }) + .addMatcher(new XiaomiMiioMdnsMatcher()) + .addMatcher(new XiaomiMiioDhcpMatcher()) + .addMatcher(new XiaomiMiioManualMatcher()) + .addValidator(new XiaomiMiioCandidateValidator()); +}; diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.mapper.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.mapper.ts new file mode 100644 index 0000000..dee4c82 --- /dev/null +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.mapper.ts @@ -0,0 +1,709 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IXiaomiMiioClientCommand, IXiaomiMiioConfig, IXiaomiMiioDeviceState, IXiaomiMiioEntityDescriptor, IXiaomiMiioEvent, IXiaomiMiioProperty, IXiaomiMiioSnapshot, IXiaomiMiioStateRecord, TXiaomiMiioDeviceKind } from './xiaomi_miio.types.js'; + +const defaultMiioPort = 54321; +const sensorUnits: Record = { + temperature: 'C', + humidity: '%', + target_humidity: '%', + water_level: '%', + battery: '%', + battery_level: '%', + pm25: 'ug/m3', + pm25_2: 'ug/m3', + pm10_density: 'ug/m3', + tvoc: 'ug/m3', + co2: 'ppm', + load_power: 'W', + motor_speed: 'rpm', + motor2_speed: 'rpm', + actual_speed: 'rpm', + favorite_speed: 'rpm', + clean_area: 'm2', + area: 'm2', + clean_time: 's', + duration: 's', + filter_life_remaining: '%', + filter_hours_used: 'h', + filter_left_time: 'd', +}; + +const primaryControlKeys = new Set(['is_on', 'on', 'power', 'brightness', 'color_temperature', 'rgb', 'hs_color', 'position', 'current_position', 'target_position', 'state', 'status', 'mode']); +const writableKeys = new Set(['is_on', 'on', 'power', 'brightness', 'color_temperature', 'rgb', 'hs_color', 'percentage', 'speed', 'fan_level', 'favorite_level', 'favorite_rpm', 'motor_speed', 'target_humidity', 'mode', 'position', 'target_position', 'oscillate', 'child_lock', 'buzzer', 'led', 'display', 'dry']); +const numberControlKeys = new Set(['fan_level', 'favorite_level', 'favorite_rpm', 'motor_speed', 'target_humidity', 'delay_off_countdown', 'led_brightness', 'led_brightness_level', 'volume', 'angle']); +const sensorKeys = new Set(['temperature', 'humidity', 'target_humidity', 'water_level', 'battery', 'battery_level', 'pm25', 'pm25_2', 'pm10_density', 'aqi', 'air_quality', 'tvoc', 'co2', 'load_power', 'motor_speed', 'motor2_speed', 'actual_speed', 'control_speed', 'favorite_speed', 'filter_life_remaining', 'filter_hours_used', 'filter_left_time', 'purify_volume', 'illuminance', 'illuminance_lux', 'clean_area', 'clean_time', 'duration', 'error']); +const topLevelStateKeys = [...primaryControlKeys, ...sensorKeys, 'state_code', 'fan_speed', 'fanspeed', 'oscillate', 'oscillating', 'preset_mode', 'current_humidity']; + +export class XiaomiMiioMapper { + public static toSnapshot(configArg: IXiaomiMiioConfig, connectedArg?: boolean, eventsArg: IXiaomiMiioEvent[] = []): IXiaomiMiioSnapshot { + const source = configArg.snapshot; + const primaryDevice = this.primaryDevice(configArg, source); + const devices = this.uniqueDevices([ + ...(source?.devices || []), + ...(source?.device ? [source.device] : []), + ...(configArg.devices || []), + ...(primaryDevice ? [primaryDevice] : []), + ]); + const connected = connectedArg ?? source?.connected ?? Boolean(source || configArg.devices?.length || configArg.state || configArg.properties?.length); + return { + connected, + configured: Boolean(configArg.host && configArg.token), + host: configArg.host || source?.host, + port: configArg.port || source?.port || defaultMiioPort, + tokenConfigured: Boolean(configArg.token || source?.tokenConfigured), + device: primaryDevice || source?.device || devices[0], + devices, + entities: [...(source?.entities || []), ...(configArg.entities || [])], + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + transport: { + protocol: source?.transport?.protocol || (source ? 'snapshot' : 'manual'), + host: configArg.host || source?.transport?.host || source?.host, + port: configArg.port || source?.transport?.port || source?.port || defaultMiioPort, + tokenConfigured: Boolean(configArg.token || source?.transport?.tokenConfigured || source?.tokenConfigured), + encryptedUdpImplemented: false, + }, + metadata: source?.metadata, + }; + } + + public static toDevices(snapshotArg: IXiaomiMiioSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const devices = new Map(); + for (const device of this.allDevices(snapshotArg)) { + const updatedAt = device.updatedAt || new Date().toISOString(); + const deviceId = this.deviceId(device); + const properties = this.propertiesForDevice(device); + const features = this.uniqueFeatures(properties.map((propertyArg) => this.featureForProperty(propertyArg, device))); + const state = properties.map((propertyArg) => ({ + featureId: this.slug(propertyArg.key), + value: this.deviceStateValue(propertyArg.value ?? this.normalizedState(device)[propertyArg.key] ?? null), + updatedAt, + })); + if (!features.length) { + features.push({ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }); + state.push({ featureId: 'availability', value: device.available === false ? 'offline' : 'online', updatedAt }); + } + devices.set(deviceId, { + id: deviceId, + integrationDomain: 'xiaomi_miio', + name: this.deviceName(device), + protocol: 'unknown', + manufacturer: device.manufacturer || 'Xiaomi', + model: device.model, + online: device.available !== false && device.online !== false && (snapshotArg.connected || Boolean(properties.length || device.host)), + features, + state, + metadata: { + miioProtocol: 'miio-udp', + host: device.host || snapshotArg.host, + port: device.port || snapshotArg.port || defaultMiioPort, + macAddress: device.macAddress, + did: device.did, + firmwareVersion: device.firmwareVersion, + hardwareVersion: device.hardwareVersion, + kind: this.inferDeviceKind(device), + tokenConfigured: Boolean(device.tokenConfigured || snapshotArg.tokenConfigured), + encryptedUdpImplemented: false, + ...device.metadata, + }, + }); + } + + for (const entity of this.allEntityDescriptors(snapshotArg)) { + const deviceId = this.entityDeviceId(snapshotArg, entity); + const device = devices.get(deviceId); + if (!device) { + continue; + } + const featureId = this.slug(entity.propertyKey || entity.property_key || entity.uniqueId || entity.unique_id || entity.id || entity.entityId || entity.name || 'entity'); + if (!device.features.some((featureArg) => featureArg.id === featureId)) { + device.features.push({ + id: featureId, + capability: this.capabilityForPlatform(this.corePlatform(entity.platform || entity.kind || 'sensor')), + name: entity.name || entity.entityId || entity.id || 'Xiaomi Miio entity', + readable: true, + writable: entity.writable === true, + unit: entity.unit, + }); + device.state.push({ featureId, value: this.deviceStateValue(entity.state ?? entity.value ?? null), updatedAt: new Date().toISOString() }); + } + } + + return [...devices.values()]; + } + + public static toEntities(snapshotArg: IXiaomiMiioSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const seen = new Set(); + const addEntity = (entityArg: IIntegrationEntity | undefined) => { + if (!entityArg || seen.has(entityArg.id)) { + return; + } + seen.add(entityArg.id); + entities.push(entityArg); + }; + + for (const descriptor of this.allEntityDescriptors(snapshotArg)) { + addEntity(this.entityFromDescriptor(snapshotArg, descriptor)); + } + + for (const device of this.allDevices(snapshotArg)) { + addEntity(this.primaryEntityForDevice(device)); + for (const property of this.propertiesForDevice(device)) { + addEntity(this.entityForProperty(device, property)); + } + } + + return entities; + } + + public static commandForService(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest): IXiaomiMiioClientCommand | undefined { + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + const targetDevice = this.findTargetDevice(snapshotArg, requestArg, targetEntity); + const kind = this.kindForCommand(requestArg, targetEntity, targetDevice); + if (['start', 'stop', 'pause', 'return_home'].includes(requestArg.service) && (kind === 'vacuum' || requestArg.domain === 'vacuum')) { + return this.command(requestArg, targetEntity, targetDevice, kind, requestArg.service, [], { action: requestArg.service }); + } + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + const value = requestArg.service === 'turn_on'; + return this.command(requestArg, targetEntity, targetDevice, kind, requestArg.service, [], { power: value }); + } + if (requestArg.service === 'set_value') { + const propertyKey = this.stringValue(requestArg.data?.propertyKey || requestArg.data?.property_key || requestArg.data?.property || targetEntity?.attributes?.propertyKey); + if (requestArg.data?.value === undefined) { + return undefined; + } + return this.command(requestArg, targetEntity, targetDevice, kind, 'set_value', [requestArg.data.value], { propertyKey, value: requestArg.data.value }); + } + return undefined; + } + + private static primaryDevice(configArg: IXiaomiMiioConfig, sourceArg?: IXiaomiMiioSnapshot): IXiaomiMiioDeviceState | undefined { + if (!configArg.host && !configArg.model && !configArg.name && !configArg.state && !configArg.properties?.length && !configArg.device) { + return undefined; + } + const base = configArg.device || sourceArg?.device || {}; + return { + ...base, + id: base.id || configArg.deviceId || configArg.macAddress || configArg.host || configArg.model || 'configured', + host: configArg.host || base.host, + port: configArg.port || base.port || defaultMiioPort, + model: configArg.model || base.model, + name: configArg.name || base.name || configArg.model || 'Xiaomi Miio device', + macAddress: configArg.macAddress || base.macAddress, + kind: configArg.kind || base.kind, + tokenConfigured: Boolean(configArg.token || base.tokenConfigured), + state: { ...this.asRecord(base.state), ...this.asRecord(configArg.state) }, + properties: [...(base.properties || []), ...(configArg.properties || [])], + entities: [...(base.entities || []), ...(configArg.entities || [])], + }; + } + + private static allDevices(snapshotArg: IXiaomiMiioSnapshot): IXiaomiMiioDeviceState[] { + const flattened: IXiaomiMiioDeviceState[] = []; + const visit = (deviceArg: IXiaomiMiioDeviceState) => { + flattened.push(deviceArg); + for (const child of deviceArg.children || []) { + visit(child); + } + }; + for (const device of snapshotArg.devices.length ? snapshotArg.devices : snapshotArg.device ? [snapshotArg.device] : []) { + visit(device); + } + return this.uniqueDevices(flattened); + } + + private static allEntityDescriptors(snapshotArg: IXiaomiMiioSnapshot): IXiaomiMiioEntityDescriptor[] { + const entities = [...snapshotArg.entities]; + for (const device of this.allDevices(snapshotArg)) { + for (const entity of device.entities || []) { + entities.push({ ...entity, deviceId: entity.deviceId || entity.device_id || this.deviceId(device), kind: entity.kind || this.inferDeviceKind(device) }); + } + } + return entities; + } + + private static uniqueDevices(devicesArg: IXiaomiMiioDeviceState[]): IXiaomiMiioDeviceState[] { + const devices = new Map(); + for (const device of devicesArg) { + devices.set(this.rawDeviceKey(device), { ...devices.get(this.rawDeviceKey(device)), ...device }); + } + return [...devices.values()]; + } + + private static uniqueFeatures(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[]): plugins.shxInterfaces.data.IDeviceFeature[] { + const features = new Map(); + for (const feature of featuresArg) { + features.set(feature.id, { ...features.get(feature.id), ...feature }); + } + return [...features.values()]; + } + + private static propertiesForDevice(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioProperty[] { + const state = this.normalizedState(deviceArg); + const properties = [...(deviceArg.properties || [])]; + const existing = new Set(properties.map((propertyArg) => propertyArg.key)); + const kind = this.inferDeviceKind(deviceArg); + for (const [key, value] of Object.entries(state)) { + if (existing.has(key) || value === undefined || this.isRecord(value) && key !== 'error') { + continue; + } + properties.push({ + key, + value, + name: this.title(key), + unit: sensorUnits[key], + readable: true, + writable: writableKeys.has(key), + platform: this.platformForProperty(key, value, kind), + }); + } + return properties; + } + + private static normalizedState(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioStateRecord { + const state: IXiaomiMiioStateRecord = { ...this.asRecord(deviceArg.state) }; + for (const key of topLevelStateKeys) { + if (deviceArg[key] !== undefined && state[key] === undefined) { + state[key] = deviceArg[key]; + } + } + if (this.isRecord(state.status)) { + for (const [key, value] of Object.entries(state.status)) { + if (state[key] === undefined) { + state[key] = value; + } + } + } + if (state.is_on === undefined && typeof state.power === 'string') { + state.is_on = ['on', 'true', '1'].includes(state.power.toLowerCase()); + } + if (state.battery === undefined && state.battery_level !== undefined) { + state.battery = state.battery_level; + } + return state; + } + + private static primaryEntityForDevice(deviceArg: IXiaomiMiioDeviceState): IIntegrationEntity | undefined { + const kind = this.inferDeviceKind(deviceArg); + const state = this.normalizedState(deviceArg); + const domain = this.entityDomainForKind(kind); + if (!domain) { + return undefined; + } + const name = this.deviceName(deviceArg); + const deviceId = this.deviceId(deviceArg); + return { + id: `${domain}.${this.slug(name)}`, + uniqueId: `xiaomi_miio_${this.slug(`${this.rawDeviceKey(deviceArg)}_${kind}`)}`, + integrationDomain: 'xiaomi_miio', + deviceId, + platform: this.corePlatform(kind === 'humidifier' ? 'climate' : kind === 'vacuum' ? 'sensor' : domain), + name, + state: this.primaryEntityState(kind, state), + attributes: { + xiaomiMiioKind: kind, + xiaomiMiioPlatform: domain, + model: deviceArg.model, + battery: state.battery, + brightness: state.brightness, + colorTemperature: state.color_temperature, + percentage: state.percentage ?? state.speed ?? state.fan_level, + presetMode: state.preset_mode ?? state.mode, + oscillating: state.oscillating ?? state.oscillate, + currentHumidity: state.current_humidity ?? state.humidity, + targetHumidity: state.target_humidity, + currentPosition: state.current_position ?? state.position, + fanSpeed: state.fan_speed ?? state.fanspeed, + error: state.error, + }, + available: deviceArg.available !== false && deviceArg.online !== false, + }; + } + + private static entityForProperty(deviceArg: IXiaomiMiioDeviceState, propertyArg: IXiaomiMiioProperty): IIntegrationEntity | undefined { + const kind = this.inferDeviceKind(deviceArg); + const key = propertyArg.key; + const platform = this.corePlatform(propertyArg.platform || this.platformForProperty(key, propertyArg.value, kind)); + if (primaryControlKeys.has(key) && platform !== 'number') { + return undefined; + } + if (!sensorKeys.has(key) && !propertyArg.unit && propertyArg.writable !== true && platform === 'sensor') { + return undefined; + } + const name = `${this.deviceName(deviceArg)} ${propertyArg.name || this.title(key)}`; + return { + id: `${platform}.${this.slug(name)}`, + uniqueId: `xiaomi_miio_${this.slug(`${this.rawDeviceKey(deviceArg)}_${key}`)}`, + integrationDomain: 'xiaomi_miio', + deviceId: this.deviceId(deviceArg), + platform, + name, + state: this.entityStateValue(propertyArg.value), + attributes: { + xiaomiMiioKind: kind, + propertyKey: key, + deviceClass: propertyArg.deviceClass, + unit: propertyArg.unit, + writable: propertyArg.writable === true, + }, + available: deviceArg.available !== false && deviceArg.online !== false, + }; + } + + private static entityFromDescriptor(snapshotArg: IXiaomiMiioSnapshot, entityArg: IXiaomiMiioEntityDescriptor): IIntegrationEntity { + const platform = this.corePlatform(entityArg.platform || entityArg.kind || 'sensor'); + const domain = this.entityDomainForExplicit(entityArg.platform || entityArg.kind || platform); + const name = entityArg.name || entityArg.entityId || entityArg.id || 'Xiaomi Miio entity'; + return { + id: entityArg.entityId || entityArg.id || `${domain}.${this.slug(name)}`, + uniqueId: entityArg.uniqueId || entityArg.unique_id || `xiaomi_miio_${this.slug(entityArg.id || entityArg.entityId || name)}`, + integrationDomain: 'xiaomi_miio', + deviceId: this.entityDeviceId(snapshotArg, entityArg), + platform, + name, + state: this.entityStateValue(entityArg.state ?? entityArg.value), + attributes: { + ...entityArg.attributes, + xiaomiMiioKind: entityArg.kind, + xiaomiMiioPlatform: entityArg.platform, + propertyKey: entityArg.propertyKey || entityArg.property_key, + deviceClass: entityArg.deviceClass || entityArg.device_class, + unit: entityArg.unit, + writable: entityArg.writable === true, + }, + available: entityArg.available !== false, + }; + } + + private static findTargetEntity(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId); + } + if (requestArg.domain === 'vacuum') { + return entities.find((entityArg) => entityArg.id.startsWith('vacuum.')); + } + return entities.find((entityArg) => entityArg.id.startsWith(`${requestArg.domain}.`)); + } + + private static findTargetDevice(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IXiaomiMiioDeviceState | undefined { + const devices = this.allDevices(snapshotArg); + const deviceId = requestArg.target.deviceId || entityArg?.deviceId; + if (deviceId) { + return devices.find((deviceArg) => this.deviceId(deviceArg) === deviceId || this.rawDeviceKey(deviceArg) === deviceId); + } + if (requestArg.domain === 'vacuum') { + return devices.find((deviceArg) => this.inferDeviceKind(deviceArg) === 'vacuum'); + } + return devices[0]; + } + + private static kindForCommand(requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity, deviceArg?: IXiaomiMiioDeviceState): TXiaomiMiioDeviceKind { + const attrKind = this.stringValue(entityArg?.attributes?.xiaomiMiioKind); + if (attrKind) { + return attrKind; + } + if (deviceArg) { + return this.inferDeviceKind(deviceArg); + } + if (requestArg.domain === 'vacuum') { + return 'vacuum'; + } + if (requestArg.domain === 'light' || requestArg.domain === 'switch' || requestArg.domain === 'fan' || requestArg.domain === 'cover') { + return requestArg.domain; + } + if (requestArg.domain === 'humidifier' || requestArg.domain === 'climate') { + return 'humidifier'; + } + return 'unknown'; + } + + private static command(requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined, deviceArg: IXiaomiMiioDeviceState | undefined, kindArg: TXiaomiMiioDeviceKind, methodArg: string, paramsArg: unknown[], payloadArg: Record): IXiaomiMiioClientCommand { + return { + type: 'service.command', + service: requestArg.service, + method: methodArg, + params: paramsArg, + platform: entityArg?.attributes?.xiaomiMiioPlatform as string | undefined || requestArg.domain, + kind: kindArg, + deviceId: entityArg?.deviceId || (deviceArg ? this.deviceId(deviceArg) : requestArg.target.deviceId), + entityId: entityArg?.id || requestArg.target.entityId, + uniqueId: entityArg?.uniqueId, + propertyKey: this.stringValue(payloadArg.propertyKey || entityArg?.attributes?.propertyKey), + value: payloadArg.value, + target: requestArg.target, + payload: { ...payloadArg, data: requestArg.data || {} }, + }; + } + + private static featureForProperty(propertyArg: IXiaomiMiioProperty, deviceArg: IXiaomiMiioDeviceState): plugins.shxInterfaces.data.IDeviceFeature { + const kind = this.inferDeviceKind(deviceArg); + return { + id: this.slug(propertyArg.key), + capability: this.capabilityForPlatform(propertyArg.platform || this.platformForProperty(propertyArg.key, propertyArg.value, kind)), + name: propertyArg.name || this.title(propertyArg.key), + readable: propertyArg.readable !== false, + writable: propertyArg.writable === true || writableKeys.has(propertyArg.key), + unit: propertyArg.unit || sensorUnits[propertyArg.key], + }; + } + + private static platformForProperty(keyArg: string, valueArg: unknown, kindArg: TXiaomiMiioDeviceKind): TEntityPlatform { + if (keyArg === 'brightness' || keyArg === 'color_temperature' || keyArg === 'rgb' || kindArg === 'light' && ['is_on', 'on', 'power'].includes(keyArg)) { + return 'light'; + } + if (keyArg === 'position' || keyArg === 'current_position' || keyArg === 'target_position' || kindArg === 'cover') { + return 'cover'; + } + if (typeof valueArg === 'number' && numberControlKeys.has(keyArg)) { + return 'number'; + } + if (kindArg === 'fan' && ['is_on', 'on', 'power', 'speed', 'percentage', 'fan_level', 'oscillate', 'oscillating'].includes(keyArg)) { + return 'fan'; + } + if (kindArg === 'humidifier' && ['is_on', 'on', 'power', 'target_humidity', 'mode'].includes(keyArg)) { + return typeof valueArg === 'number' && keyArg === 'target_humidity' ? 'number' : 'climate'; + } + if (typeof valueArg === 'number' && writableKeys.has(keyArg) && !sensorKeys.has(keyArg)) { + return 'number'; + } + if (typeof valueArg === 'boolean' && !['is_on', 'on', 'power'].includes(keyArg)) { + return 'switch'; + } + if (kindArg === 'switch' && ['is_on', 'on', 'power'].includes(keyArg)) { + return 'switch'; + } + return 'sensor'; + } + + private static inferDeviceKind(deviceArg: IXiaomiMiioDeviceState): TXiaomiMiioDeviceKind { + const explicit = this.stringValue(deviceArg.kind || deviceArg.type || deviceArg.platform)?.toLowerCase(); + if (explicit && explicit !== 'device') { + return explicit; + } + const model = (deviceArg.model || '').toLowerCase(); + const state = this.normalizedStateShallow(deviceArg); + if (model.includes('vacuum') || model.startsWith('roborock.') || model.startsWith('rockrobo.') || state.state_code !== undefined && (state.clean_area !== undefined || state.battery !== undefined)) { + return 'vacuum'; + } + if (model.includes('humidifier') || model.includes('deerma.humidifier') || state.target_humidity !== undefined) { + return 'humidifier'; + } + if (model.includes('airpurifier') || model.includes('airfresh') || model.includes('.fan.') || state.fan_level !== undefined || state.oscillate !== undefined || state.motor_speed !== undefined) { + return 'fan'; + } + if (model.includes('light') || state.brightness !== undefined || state.color_temperature !== undefined || state.rgb !== undefined) { + return 'light'; + } + if (model.includes('plug') || model.includes('powerstrip') || model.includes('switch')) { + return 'switch'; + } + if (model.includes('curtain') || model.includes('cover') || state.position !== undefined || state.current_position !== undefined) { + return 'cover'; + } + if (model.includes('gateway')) { + return 'gateway'; + } + if (model.includes('airmonitor') || state.aqi !== undefined || state.pm25 !== undefined) { + return 'sensor'; + } + if (state.is_on !== undefined || state.power !== undefined) { + return 'switch'; + } + return 'sensor'; + } + + private static normalizedStateShallow(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioStateRecord { + const state: IXiaomiMiioStateRecord = { ...this.asRecord(deviceArg.state) }; + for (const key of topLevelStateKeys) { + if (deviceArg[key] !== undefined && state[key] === undefined) { + state[key] = deviceArg[key]; + } + } + if (this.isRecord(state.status)) { + for (const [key, value] of Object.entries(state.status)) { + if (state[key] === undefined) { + state[key] = value; + } + } + } + return state; + } + + private static primaryEntityState(kindArg: TXiaomiMiioDeviceKind, stateArg: IXiaomiMiioStateRecord): unknown { + if (kindArg === 'vacuum') { + return this.vacuumActivity(stateArg); + } + if (kindArg === 'cover') { + const position = this.numberValue(stateArg.current_position ?? stateArg.position); + if (position !== undefined) { + return position <= 0 ? 'closed' : 'open'; + } + return stateArg.state ?? 'unknown'; + } + if (kindArg === 'sensor') { + return stateArg.aqi ?? stateArg.pm25 ?? stateArg.temperature ?? stateArg.humidity ?? 'unknown'; + } + return this.powerState(stateArg); + } + + private static vacuumActivity(stateArg: IXiaomiMiioStateRecord): string { + const stateCode = this.numberValue(stateArg.state_code); + if (stateCode !== undefined) { + if ([5, 7, 11, 16, 17, 18].includes(stateCode)) { + return 'cleaning'; + } + if ([6, 15, 26].includes(stateCode)) { + return 'returning'; + } + if ([8, 22, 23, 100].includes(stateCode)) { + return 'docked'; + } + if (stateCode === 10) { + return 'paused'; + } + if ([9, 12, 101].includes(stateCode)) { + return 'error'; + } + return 'idle'; + } + return this.stringValue(stateArg.state || stateArg.status) || 'unknown'; + } + + private static powerState(stateArg: IXiaomiMiioStateRecord): string { + const value = stateArg.is_on ?? stateArg.on ?? stateArg.power; + if (typeof value === 'boolean') { + return value ? 'on' : 'off'; + } + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + if (['on', 'true', '1'].includes(normalized)) { + return 'on'; + } + if (['off', 'false', '0'].includes(normalized)) { + return 'off'; + } + return normalized; + } + return 'unknown'; + } + + private static entityDeviceId(snapshotArg: IXiaomiMiioSnapshot, entityArg: IXiaomiMiioEntityDescriptor): string { + const explicit = entityArg.deviceId || entityArg.device_id; + if (explicit) { + return explicit; + } + const device = this.allDevices(snapshotArg).find((deviceArg) => entityArg.deviceId === this.deviceId(deviceArg) || entityArg.device_id === this.deviceId(deviceArg)); + return device ? this.deviceId(device) : this.deviceId(snapshotArg.device || snapshotArg.devices[0] || { id: 'configured' }); + } + + private static deviceId(deviceArg: IXiaomiMiioDeviceState): string { + return `xiaomi_miio.device.${this.slug(this.rawDeviceKey(deviceArg))}`; + } + + private static rawDeviceKey(deviceArg: IXiaomiMiioDeviceState): string { + return String(deviceArg.id || deviceArg.did || deviceArg.macAddress || deviceArg.host || deviceArg.model || deviceArg.name || 'configured'); + } + + private static deviceName(deviceArg: IXiaomiMiioDeviceState): string { + return deviceArg.name || deviceArg.model || deviceArg.host || 'Xiaomi Miio device'; + } + + private static entityDomainForKind(kindArg: TXiaomiMiioDeviceKind): string | undefined { + if (kindArg === 'vacuum') { + return 'vacuum'; + } + if (kindArg === 'humidifier') { + return 'humidifier'; + } + if (kindArg === 'light' || kindArg === 'switch' || kindArg === 'fan' || kindArg === 'cover') { + return kindArg; + } + if (kindArg === 'sensor' || kindArg === 'gateway') { + return 'sensor'; + } + return undefined; + } + + private static entityDomainForExplicit(platformArg: unknown): string { + const platform = String(platformArg || 'sensor'); + if (platform === 'vacuum' || platform === 'humidifier') { + return platform; + } + return this.corePlatform(platform); + } + + private static corePlatform(platformArg: unknown): TEntityPlatform { + const platform = String(platformArg || 'sensor').toLowerCase(); + if (platform === 'vacuum') { + return 'sensor'; + } + if (platform === 'humidifier') { + return 'climate'; + } + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + private static capabilityForPlatform(platformArg: unknown): plugins.shxInterfaces.data.TDeviceCapability { + const platform = String(platformArg || 'sensor').toLowerCase(); + if (platform === 'light') { + return 'light'; + } + if (platform === 'cover') { + return 'cover'; + } + if (platform === 'fan' || platform === 'vacuum') { + return 'fan'; + } + if (platform === 'climate' || platform === 'humidifier') { + return 'climate'; + } + return platform === 'switch' || platform === 'number' || platform === 'select' || platform === 'button' ? 'switch' : 'sensor'; + } + + private static entityStateValue(valueArg: unknown): unknown { + if (valueArg === undefined) { + return 'unknown'; + } + if (typeof valueArg === 'boolean') { + return valueArg ? 'on' : 'off'; + } + return valueArg; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return Array.isArray(valueArg) ? JSON.stringify(valueArg) : String(valueArg); + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static asRecord(valueArg: unknown): Record { + return this.isRecord(valueArg) ? valueArg : {}; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static title(valueArg: string): string { + return valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static slug(valueArg: string): string { + return String(valueArg).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'xiaomi_miio'; + } +} diff --git a/ts/integrations/xiaomi_miio/xiaomi_miio.types.ts b/ts/integrations/xiaomi_miio/xiaomi_miio.types.ts index 714051e..3f0094c 100644 --- a/ts/integrations/xiaomi_miio/xiaomi_miio.types.ts +++ b/ts/integrations/xiaomi_miio/xiaomi_miio.types.ts @@ -1,4 +1,208 @@ -export interface IHomeAssistantXiaomiMiioConfig { - // TODO: replace with the TypeScript-native config for xiaomi_miio. +import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js'; + +export type TXiaomiMiioDeviceKind = + | 'vacuum' + | 'fan' + | 'light' + | 'switch' + | 'sensor' + | 'cover' + | 'humidifier' + | 'gateway' + | 'unknown' + | string; + +export type TXiaomiMiioEntityPlatform = TEntityPlatform | 'vacuum' | 'humidifier' | string; + +export interface IXiaomiMiioConfig { + host?: string; + port?: number; + token?: string; + model?: string; + name?: string; + deviceId?: string; + macAddress?: string; + kind?: TXiaomiMiioDeviceKind; + device?: IXiaomiMiioDeviceState; + devices?: IXiaomiMiioDeviceState[]; + entities?: IXiaomiMiioEntityDescriptor[]; + state?: IXiaomiMiioStateRecord; + properties?: IXiaomiMiioProperty[]; + snapshot?: IXiaomiMiioSnapshot; + events?: IXiaomiMiioEvent[]; + commandExecutor?: TXiaomiMiioCommandExecutor; + [key: string]: unknown; +} + +export interface IHomeAssistantXiaomiMiioConfig extends IXiaomiMiioConfig {} + +export interface IXiaomiMiioDeviceInfo { + id?: string; + did?: string | number; + name?: string; + model?: string; + manufacturer?: string; + host?: string; + port?: number; + macAddress?: string; + firmwareVersion?: string; + hardwareVersion?: string; + serialNumber?: string; + kind?: TXiaomiMiioDeviceKind; + tokenConfigured?: boolean; + metadata?: Record; + [key: string]: unknown; +} + +export interface IXiaomiMiioDeviceState extends IXiaomiMiioDeviceInfo { + available?: boolean; + online?: boolean; + updatedAt?: string; + state?: IXiaomiMiioStateRecord; + properties?: IXiaomiMiioProperty[]; + entities?: IXiaomiMiioEntityDescriptor[]; + children?: IXiaomiMiioDeviceState[]; +} + +export type IXiaomiMiioStateRecord = Record; + +export interface IXiaomiMiioProperty { + key: string; + value?: unknown; + name?: string; + unit?: string; + readable?: boolean; + writable?: boolean; + platform?: TXiaomiMiioEntityPlatform; + kind?: TXiaomiMiioDeviceKind; + deviceClass?: string; + category?: string; + siid?: number; + piid?: number; + metadata?: Record; + [key: string]: unknown; +} + +export interface IXiaomiMiioEntityDescriptor { + id?: string; + entityId?: string; + uniqueId?: string; + unique_id?: string; + deviceId?: string; + device_id?: string; + platform?: TXiaomiMiioEntityPlatform; + kind?: TXiaomiMiioDeviceKind; + name?: string; + state?: unknown; + value?: unknown; + propertyKey?: string; + property_key?: string; + attributes?: Record; + available?: boolean; + writable?: boolean; + unit?: string; + deviceClass?: string; + device_class?: string; + [key: string]: unknown; +} + +export interface IXiaomiMiioSnapshot { + connected: boolean; + configured: boolean; + host?: string; + port?: number; + tokenConfigured?: boolean; + device?: IXiaomiMiioDeviceState; + devices: IXiaomiMiioDeviceState[]; + entities: IXiaomiMiioEntityDescriptor[]; + events: IXiaomiMiioEvent[]; + transport?: IXiaomiMiioTransportInfo; + metadata?: Record; +} + +export interface IXiaomiMiioTransportInfo { + host?: string; + port?: number; + protocol: 'miio-udp' | 'snapshot' | 'manual' | string; + tokenConfigured: boolean; + encryptedUdpImplemented: boolean; +} + +export interface IXiaomiMiioEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + uniqueId?: string; + command?: IXiaomiMiioClientCommand; + data?: unknown; + [key: string]: unknown; +} + +export interface IXiaomiMiioClientCommand { + type: string; + service: string; + method?: string; + params?: unknown[] | Record; + platform?: TXiaomiMiioEntityPlatform; + kind?: TXiaomiMiioDeviceKind; + deviceId?: string; + entityId?: string; + uniqueId?: string; + propertyKey?: string; + value?: unknown; + target?: { + entityId?: string; + deviceId?: string; + }; + payload: Record; +} + +export interface IXiaomiMiioCommandResult extends IServiceCallResult {} + +export type TXiaomiMiioCommandExecutor = ( + commandArg: IXiaomiMiioClientCommand +) => Promise | IXiaomiMiioCommandResult | unknown; + +export interface IXiaomiMiioMdnsRecord { + type?: string; + serviceType?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; + [key: string]: unknown; +} + +export interface IXiaomiMiioDhcpRecord { + host?: string; + ipAddress?: string; + address?: string; + hostname?: string; + hostName?: string; + macAddress?: string; + mac?: string; + manufacturer?: string; + model?: string; + vendorClassIdentifier?: string; + options?: Record; + metadata?: Record; + [key: string]: unknown; +} + +export interface IXiaomiMiioManualEntry { + host?: string; + port?: number; + token?: string; + model?: string; + name?: string; + id?: string; + macAddress?: string; + deviceId?: string; + snapshot?: IXiaomiMiioSnapshot; + metadata?: Record; [key: string]: unknown; } diff --git a/ts/integrations/yeelight/.generated-by-smarthome-exchange b/ts/integrations/yeelight/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/yeelight/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/yeelight/index.ts b/ts/integrations/yeelight/index.ts index 4205824..bb88274 100644 --- a/ts/integrations/yeelight/index.ts +++ b/ts/integrations/yeelight/index.ts @@ -1,2 +1,6 @@ +export * from './yeelight.classes.client.js'; +export * from './yeelight.classes.configflow.js'; export * from './yeelight.classes.integration.js'; +export * from './yeelight.discovery.js'; +export * from './yeelight.mapper.js'; export * from './yeelight.types.js'; diff --git a/ts/integrations/yeelight/yeelight.classes.client.ts b/ts/integrations/yeelight/yeelight.classes.client.ts new file mode 100644 index 0000000..93d3d68 --- /dev/null +++ b/ts/integrations/yeelight/yeelight.classes.client.ts @@ -0,0 +1,323 @@ +import * as plugins from '../../plugins.js'; +import type { + IYeelightBulbInfo, + IYeelightBulbProperties, + IYeelightCommand, + IYeelightCommandResponse, + IYeelightCommandResult, + IYeelightConfig, + IYeelightEvent, + IYeelightLightStatePatch, + IYeelightManualDeviceConfig, + IYeelightSnapshot, + TYeelightEffect, +} from './yeelight.types.js'; + +type TYeelightEventHandler = (eventArg: IYeelightEvent) => void; + +const defaultPort = 55443; +const defaultEffect: TYeelightEffect = 'smooth'; +const defaultTransition = 350; +const defaultPropertyRequest = [ + 'power', + 'main_power', + 'bright', + 'ct', + 'rgb', + 'hue', + 'sat', + 'color_mode', + 'flowing', + 'delayoff', + 'music_on', + 'name', + 'bg_power', + 'bg_flowing', + 'bg_ct', + 'bg_bright', + 'bg_hue', + 'bg_sat', + 'bg_rgb', + 'bg_lmode', + 'nl_br', + 'active_mode', +]; + +export class YeelightClient { + private commandId = 1; + private cachedSnapshot?: IYeelightSnapshot; + private readonly events: IYeelightEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IYeelightConfig) {} + + public async getSnapshot(): Promise { + if (this.cachedSnapshot) { + return this.withEvents(this.cachedSnapshot); + } + if (this.config.snapshot) { + this.cachedSnapshot = this.withEvents({ ...this.config.snapshot, source: this.config.snapshot.source || 'snapshot' }); + return this.cachedSnapshot; + } + const manualBulbs = this.manualBulbs(); + if (manualBulbs.length) { + this.cachedSnapshot = this.withEvents({ connected: false, bulbs: manualBulbs, events: [], source: 'manual', updatedAt: new Date().toISOString() }); + return this.cachedSnapshot; + } + if (!this.config.host) { + throw new Error('Yeelight snapshot requires config.snapshot, config.bulb/config.bulbs/config.devices, or a host for local TCP get_prop.'); + } + const properties = await this.getProperties(defaultPropertyRequest); + this.cachedSnapshot = this.withEvents({ + connected: true, + bulbs: [this.bulbFromConfig(properties)], + events: [], + source: 'tcp', + updatedAt: new Date().toISOString(), + }); + return this.cachedSnapshot; + } + + public onEvent(handlerArg: TYeelightEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async getProperties(propertiesArg: string[] = defaultPropertyRequest): Promise { + const response = await this.sendCommand({ method: 'get_prop', params: propertiesArg }); + const result = response.result || []; + const properties: IYeelightBulbProperties = {}; + for (const [index, property] of propertiesArg.entries()) { + const value = result[index]; + properties[property] = value === '' || value === undefined ? null : String(value); + } + this.emit({ type: 'properties', host: this.config.host, data: properties, timestamp: Date.now() }); + return properties; + } + + public async setLightState(patchArg: IYeelightLightStatePatch): Promise { + const effect = patchArg.effect || this.config.effect || defaultEffect; + const transition = this.transitionValue(patchArg.transition); + if (patchArg.on !== undefined) { + await this.setPower(patchArg.on, effect, transition); + } + if (patchArg.colorTempKelvin !== undefined) { + await this.setColorTemp(patchArg.colorTempKelvin, effect, transition); + } + if (patchArg.rgbColor !== undefined) { + await this.setRgb(patchArg.rgbColor, effect, transition); + } + const brightness = patchArg.brightness ?? patchArg.percentage; + if (brightness !== undefined) { + await this.setBrightness(brightness, effect, transition); + } + } + + public async setPower(onArg: boolean, effectArg = defaultEffect, durationArg = defaultTransition): Promise { + await this.sendCommand({ method: 'set_power', params: [onArg ? 'on' : 'off', effectArg, durationArg] }); + } + + public async setBrightness(brightnessArg: number, effectArg = defaultEffect, durationArg = defaultTransition): Promise { + await this.sendCommand({ method: 'set_bright', params: [this.clamp(Math.round(brightnessArg), 1, 100), effectArg, durationArg] }); + } + + public async setColorTemp(kelvinArg: number, effectArg = defaultEffect, durationArg = defaultTransition): Promise { + await this.sendCommand({ method: 'set_ct_abx', params: [this.clamp(Math.round(kelvinArg), 1700, 6500), effectArg, durationArg] }); + } + + public async setRgb(rgbArg: [number, number, number], effectArg = defaultEffect, durationArg = defaultTransition): Promise { + const [red, green, blue] = rgbArg.map((valueArg) => this.clamp(Math.round(valueArg), 0, 255)); + await this.sendCommand({ method: 'set_rgb', params: [red * 65536 + green * 256 + blue, effectArg, durationArg] }); + } + + public async sendCommand(commandArg: IYeelightCommand): Promise { + const command = { + ...commandArg, + id: commandArg.id ?? this.commandId++, + host: commandArg.host || this.config.host, + port: commandArg.port || this.config.port || defaultPort, + params: commandArg.params || [], + }; + this.emit({ type: 'command', host: command.host, command, timestamp: Date.now() }); + if (this.config.commandExecutor) { + return this.commandExecutorResponse(await this.config.commandExecutor(command), command); + } + if (!command.host) { + throw new Error('Yeelight live TCP commands require config.host. Snapshot/manual configs are read-only without commandExecutor.'); + } + return this.sendTcpCommand({ ...command, host: command.host }); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private manualBulbs(): IYeelightBulbInfo[] { + const bulbs: IYeelightBulbInfo[] = []; + if (this.config.bulb) { + bulbs.push(this.config.bulb); + } + if (this.config.bulbs) { + bulbs.push(...this.config.bulbs); + } + if (this.config.devices) { + for (const [host, deviceConfig] of Object.entries(this.config.devices)) { + bulbs.push(this.bulbFromManualDevice(host, deviceConfig)); + } + } + if (!bulbs.length && this.config.host && (this.config.name || this.config.model || this.config.id)) { + bulbs.push(this.bulbFromConfig()); + } + return bulbs; + } + + private bulbFromManualDevice(hostArg: string, configArg: IYeelightManualDeviceConfig): IYeelightBulbInfo { + return { + id: configArg.id, + host: hostArg, + port: configArg.port || defaultPort, + name: configArg.name, + model: configArg.model || configArg.detectedModel || configArg.detected_model, + capabilities: configArg.capabilities, + properties: configArg.properties, + support: configArg.capabilities?.support?.split(' ').filter(Boolean), + available: false, + }; + } + + private bulbFromConfig(propertiesArg?: IYeelightBulbProperties): IYeelightBulbInfo { + return { + id: this.config.id, + host: this.config.host, + port: this.config.port || defaultPort, + name: this.config.name || propertiesArg?.name || undefined, + model: this.config.model || this.config.detectedModel || this.config.detected_model, + properties: propertiesArg, + available: Boolean(propertiesArg), + }; + } + + private async sendTcpCommand(commandArg: Required>): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let buffer = ''; + const socket = plugins.net.createConnection({ host: commandArg.host, port: commandArg.port }); + const timeout = setTimeout(() => { + finish(new Error(`Yeelight TCP command ${commandArg.method} timed out for ${commandArg.host}:${commandArg.port}.`)); + }, 5000); + + const finish = (errorArg?: Error, responseArg?: IYeelightCommandResponse) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + this.emit({ type: 'error', host: commandArg.host, command: commandArg, error: errorArg.message, timestamp: Date.now() }); + reject(errorArg); + } else { + resolve(responseArg || { id: commandArg.id, result: [] }); + } + }; + + socket.on('connect', () => { + socket.write(`${JSON.stringify({ id: commandArg.id, method: commandArg.method, params: commandArg.params })}\r\n`); + }); + socket.on('data', (chunkArg) => { + buffer += chunkArg.toString('utf8'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) { + continue; + } + const response = this.parseResponseLine(line); + if (!response) { + continue; + } + if (response.method === 'props') { + this.emit({ type: 'properties', host: commandArg.host, data: response.params, timestamp: Date.now() }); + continue; + } + if (response.error) { + finish(new Error(`Yeelight command ${commandArg.method} failed: ${JSON.stringify(response.error)}`)); + return; + } + finish(undefined, response); + return; + } + }); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => { + if (!settled) { + const response = this.parseResponseLine(buffer); + if (response && !response.error) { + finish(undefined, response); + } else { + finish(new Error(`Yeelight TCP connection closed before ${commandArg.method} response.`)); + } + } + }); + }); + } + + private parseResponseLine(lineArg: string): IYeelightCommandResponse | undefined { + try { + return JSON.parse(lineArg) as IYeelightCommandResponse; + } catch { + return undefined; + } + } + + private commandExecutorResponse(resultArg: unknown, commandArg: IYeelightCommand): IYeelightCommandResponse { + if (this.isCommandResponse(resultArg)) { + return resultArg; + } + if (this.isCommandResult(resultArg)) { + if (!resultArg.success) { + throw new Error(resultArg.error || `Yeelight command ${commandArg.method} failed.`); + } + return { id: commandArg.id, result: [resultArg.data ?? 'ok'] }; + } + return { id: commandArg.id, result: [resultArg ?? 'ok'] }; + } + + private withEvents(snapshotArg: IYeelightSnapshot): IYeelightSnapshot { + return { ...snapshotArg, events: [...(snapshotArg.events || []), ...this.events] }; + } + + private emit(eventArg: IYeelightEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private transitionValue(valueArg?: number): number { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return Math.round(valueArg >= 0 && valueArg < 100 ? valueArg * 1000 : valueArg); + } + if (typeof this.config.transition === 'number' && Number.isFinite(this.config.transition)) { + return this.config.transition; + } + return defaultTransition; + } + + private clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.min(maxArg, Math.max(minArg, valueArg)); + } + + private isCommandResponse(valueArg: unknown): valueArg is IYeelightCommandResponse { + return this.isRecord(valueArg) && ('result' in valueArg || 'error' in valueArg || 'method' in valueArg); + } + + private isCommandResult(valueArg: unknown): valueArg is IYeelightCommandResult { + return this.isRecord(valueArg) && typeof valueArg.success === 'boolean'; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/yeelight/yeelight.classes.configflow.ts b/ts/integrations/yeelight/yeelight.classes.configflow.ts new file mode 100644 index 0000000..cf70217 --- /dev/null +++ b/ts/integrations/yeelight/yeelight.classes.configflow.ts @@ -0,0 +1,46 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IYeelightConfig } from './yeelight.types.js'; + +const defaultPort = 55443; + +export class YeelightConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Yeelight', + description: 'Configure local LAN control for a Yeelight bulb. LAN control must be enabled on the bulb for live commands.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'model', label: 'Model', type: 'text' }, + { name: 'name', label: 'Name', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { kind: 'error', title: 'Missing Yeelight host', error: 'Host is required for Yeelight local LAN control.' }; + } + return { + kind: 'done', + title: 'Yeelight configured', + config: { + host, + port: this.numberValue(valuesArg.port) || candidateArg.port || defaultPort, + model: this.stringValue(valuesArg.model) || candidateArg.model, + name: this.stringValue(valuesArg.name) || candidateArg.name, + id: candidateArg.id, + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } +} diff --git a/ts/integrations/yeelight/yeelight.classes.integration.ts b/ts/integrations/yeelight/yeelight.classes.integration.ts index abf10a0..d81a97d 100644 --- a/ts/integrations/yeelight/yeelight.classes.integration.ts +++ b/ts/integrations/yeelight/yeelight.classes.integration.ts @@ -1,34 +1,131 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { YeelightClient } from './yeelight.classes.client.js'; +import { YeelightConfigFlow } from './yeelight.classes.configflow.js'; +import { createYeelightDiscoveryDescriptor } from './yeelight.discovery.js'; +import { YeelightMapper } from './yeelight.mapper.js'; +import type { IYeelightConfig, IYeelightLightStatePatch } from './yeelight.types.js'; -export class HomeAssistantYeelightIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "yeelight", - displayName: "Yeelight", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/yeelight", - "upstreamDomain": "yeelight", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "yeelight==0.7.16", - "async-upnp-client==0.46.2" - ], - "dependencies": [ - "network" - ], - "afterDependencies": [ - "ssdp" - ], - "codeowners": [ - "@zewelor", - "@shenxn", - "@starkillerOG", - "@alexyao2015" - ] -}, - }); +export class YeelightIntegration extends BaseIntegration { + public readonly domain = 'yeelight'; + public readonly displayName = 'Yeelight'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createYeelightDiscoveryDescriptor(); + public readonly configFlow = new YeelightConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/yeelight', + upstreamDomain: 'yeelight', + integrationType: 'device', + iotClass: 'local_push', + documentation: 'https://www.home-assistant.io/integrations/yeelight', + requirements: ['yeelight==0.7.16', 'async-upnp-client==0.46.2'], + dependencies: ['network'], + afterDependencies: ['ssdp'], + codeowners: ['@zewelor', '@shenxn', '@starkillerOG', '@alexyao2015'], + }; + + public async setup(configArg: IYeelightConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new YeelightRuntime(new YeelightClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantYeelightIntegration extends YeelightIntegration {} + +class YeelightRuntime implements IIntegrationRuntime { + public domain = 'yeelight'; + + constructor(private readonly client: YeelightClient) {} + + public async devices(): Promise { + return YeelightMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return YeelightMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: ReturnType) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(YeelightMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (!['light', 'number'].includes(requestArg.domain)) { + return { success: false, error: `Unsupported Yeelight service domain: ${requestArg.domain}` }; + } + const patch = this.patchForService(requestArg); + if (!patch) { + return { success: false, error: `Unsupported Yeelight service: ${requestArg.service}` }; + } + await this.client.setLightState(patch); + return { success: true }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private patchForService(requestArg: IServiceCallRequest): IYeelightLightStatePatch | undefined { + if (requestArg.service === 'turn_on') { + return { on: true, ...this.patchFromData(requestArg.data) }; + } + if (requestArg.service === 'turn_off') { + return { on: false, transition: this.numberValue(requestArg.data?.transition) }; + } + if (requestArg.service === 'set_brightness' || requestArg.service === 'set_percentage') { + const value = this.numberValue(requestArg.data?.brightness ?? requestArg.data?.percentage ?? requestArg.data?.value); + return value === undefined ? undefined : { percentage: value, transition: this.numberValue(requestArg.data?.transition) }; + } + if (requestArg.service === 'set_value') { + const property = String(requestArg.data?.property || requestArg.data?.attribute || '').toLowerCase(); + const value = requestArg.data?.value; + if (property === 'rgb' || property === 'rgb_color') { + const rgb = this.rgbValue(value); + return rgb ? { rgbColor: rgb, transition: this.numberValue(requestArg.data?.transition) } : undefined; + } + if (property === 'ct' || property === 'color_temp' || property === 'color_temp_kelvin' || property === 'kelvin') { + const kelvin = this.numberValue(value); + return kelvin === undefined ? undefined : { colorTempKelvin: kelvin, transition: this.numberValue(requestArg.data?.transition) }; + } + const numeric = this.numberValue(value); + return numeric === undefined ? undefined : { percentage: numeric, transition: this.numberValue(requestArg.data?.transition) }; + } + return undefined; + } + + private patchFromData(dataArg?: Record): IYeelightLightStatePatch { + const rgb = this.rgbValue(dataArg?.rgbColor ?? dataArg?.rgb_color); + return { + brightness: this.numberValue(dataArg?.brightness), + percentage: this.numberValue(dataArg?.percentage), + colorTempKelvin: this.numberValue(dataArg?.colorTempKelvin ?? dataArg?.color_temp_kelvin ?? dataArg?.kelvin), + rgbColor: rgb, + transition: this.numberValue(dataArg?.transition), + }; + } + + private rgbValue(valueArg: unknown): [number, number, number] | undefined { + if (!Array.isArray(valueArg) || valueArg.length < 3) { + return undefined; + } + const red = this.numberValue(valueArg[0]); + const green = this.numberValue(valueArg[1]); + const blue = this.numberValue(valueArg[2]); + return red === undefined || green === undefined || blue === undefined ? undefined : [red, green, blue]; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; } } diff --git a/ts/integrations/yeelight/yeelight.discovery.ts b/ts/integrations/yeelight/yeelight.discovery.ts new file mode 100644 index 0000000..144e34c --- /dev/null +++ b/ts/integrations/yeelight/yeelight.discovery.ts @@ -0,0 +1,170 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IYeelightManualEntry, IYeelightMdnsRecord, IYeelightSsdpRecord } from './yeelight.types.js'; + +const defaultPort = 55443; + +export class YeelightSsdpMatcher implements IDiscoveryMatcher { + public id = 'yeelight-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Yeelight local LAN SSDP wifi_bulb responses.'; + + public async matches(recordArg: IYeelightSsdpRecord): Promise { + const headers = this.normalizedHeaders(recordArg); + const location = headers.location; + const support = headers.support || ''; + if (!headers.id || !location || !(headers.st === 'wifi_bulb' || support.includes('set_power') || headers.model)) { + return { matched: false, confidence: 'low', reason: 'SSDP response is not a Yeelight wifi_bulb advertisement.' }; + } + const endpoint = this.parseLocation(location); + return { + matched: true, + confidence: headers.id && endpoint.host ? 'certain' : 'high', + reason: 'SSDP response contains Yeelight LAN control capabilities.', + normalizedDeviceId: headers.id, + candidate: { + source: 'ssdp', + integrationDomain: 'yeelight', + id: headers.id, + host: endpoint.host, + port: endpoint.port || defaultPort, + name: headers.name, + manufacturer: 'Yeelight', + model: headers.model, + metadata: { + location, + support: support.split(' ').filter(Boolean), + firmwareVersion: headers.fw_ver, + capabilities: headers, + }, + }, + }; + } + + private normalizedHeaders(recordArg: IYeelightSsdpRecord): Record { + const source = { ...recordArg.headers, ...recordArg.ssdp_headers, ...recordArg } as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (typeof value === 'string') { + normalized[key.toLowerCase()] = value; + } + } + return normalized; + } + + private parseLocation(locationArg: string): { host?: string; port?: number } { + try { + const parsed = new URL(locationArg); + return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined }; + } catch { + const match = locationArg.match(/([0-9a-z.-]+):(\d+)/i); + return match ? { host: match[1], port: Number(match[2]) } : {}; + } + } +} + +export class YeelightMdnsMatcher implements IDiscoveryMatcher { + public id = 'yeelight-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Yeelight zeroconf records from yeelink names and _miio._udp.local.'; + + public async matches(recordArg: IYeelightMdnsRecord): Promise { + const type = (recordArg.type || '').toLowerCase().replace(/\.$/, ''); + const name = (recordArg.name || recordArg.hostname || '').toLowerCase(); + const txt = recordArg.txt || recordArg.properties || {}; + const model = txt.model || txt.modelid || recordArg.txt?.md || undefined; + const matched = name.startsWith('yeelink-') || type === '_miio._udp.local' || Boolean(model?.startsWith('YL')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Yeelight advertisement.' }; + } + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const deviceId = txt.id || txt.mac || txt.serial || recordArg.name || host; + return { + matched: true, + confidence: host ? 'high' : 'medium', + reason: 'mDNS record matches Yeelight zeroconf metadata.', + normalizedDeviceId: deviceId, + candidate: { + source: 'mdns', + integrationDomain: 'yeelight', + id: deviceId, + host, + port: recordArg.port || defaultPort, + name: recordArg.name, + manufacturer: 'Yeelight', + model, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + }, + }, + }; + } +} + +export class YeelightManualMatcher implements IDiscoveryMatcher { + public id = 'yeelight-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Yeelight setup entries by host or Yeelight metadata.'; + + public async matches(inputArg: IYeelightManualEntry): Promise { + const model = inputArg.model?.toLowerCase() || ''; + const name = inputArg.name?.toLowerCase() || ''; + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || inputArg.snapshot || inputArg.capabilities || inputArg.metadata?.yeelight || model.includes('yeelight') || name.includes('yeelight') || manufacturer === 'yeelight'); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Yeelight setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Yeelight setup.', + normalizedDeviceId: inputArg.id || inputArg.host, + candidate: { + source: 'manual', + integrationDomain: 'yeelight', + id: inputArg.id, + host: inputArg.host, + port: inputArg.port || defaultPort, + name: inputArg.name, + manufacturer: 'Yeelight', + model: inputArg.model, + metadata: { + ...inputArg.metadata, + properties: inputArg.properties, + capabilities: inputArg.capabilities, + snapshot: inputArg.snapshot, + }, + }, + }; + } +} + +export class YeelightCandidateValidator implements IDiscoveryValidator { + public id = 'yeelight-candidate-validator'; + public description = 'Validate Yeelight candidates before starting local LAN setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const name = candidateArg.name?.toLowerCase() || ''; + const metadata = candidateArg.metadata || {}; + const matched = candidateArg.integrationDomain === 'yeelight' || manufacturer === 'yeelight' || model.includes('yeelight') || name.includes('yeelight') || name.startsWith('yeelink-') || Boolean(metadata.capabilities || metadata.yeelight); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Yeelight metadata.' : 'Candidate is not Yeelight.', + candidate: matched ? { ...candidateArg, port: candidateArg.port || defaultPort } : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.host, + }; + } +} + +export const createYeelightDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'yeelight', displayName: 'Yeelight' }) + .addMatcher(new YeelightSsdpMatcher()) + .addMatcher(new YeelightMdnsMatcher()) + .addMatcher(new YeelightManualMatcher()) + .addValidator(new YeelightCandidateValidator()); +}; diff --git a/ts/integrations/yeelight/yeelight.mapper.ts b/ts/integrations/yeelight/yeelight.mapper.ts new file mode 100644 index 0000000..f3e1575 --- /dev/null +++ b/ts/integrations/yeelight/yeelight.mapper.ts @@ -0,0 +1,219 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { IYeelightBulbInfo, IYeelightBulbProperties, IYeelightEvent, IYeelightSnapshot } from './yeelight.types.js'; + +const sensorProperties: Array<{ key: keyof IYeelightBulbProperties; name: string; unit?: string; binary?: boolean }> = [ + { key: 'ct', name: 'Color temperature', unit: 'K' }, + { key: 'rgb', name: 'RGB' }, + { key: 'hue', name: 'Hue' }, + { key: 'sat', name: 'Saturation', unit: '%' }, + { key: 'color_mode', name: 'Color mode' }, + { key: 'flowing', name: 'Color flow', binary: true }, + { key: 'active_mode', name: 'Active mode' }, + { key: 'nl_br', name: 'Nightlight brightness', unit: '%' }, + { key: 'bg_power', name: 'Ambient power', binary: true }, + { key: 'bg_bright', name: 'Ambient brightness', unit: '%' }, + { key: 'bg_ct', name: 'Ambient color temperature', unit: 'K' }, + { key: 'bg_rgb', name: 'Ambient RGB' }, +]; + +export class YeelightMapper { + public static toDevices(snapshotArg: IYeelightSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + return snapshotArg.bulbs.map((bulbArg) => { + const state = this.bulbState(bulbArg); + const properties = bulbArg.properties || {}; + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'power', capability: 'light', name: 'Power', readable: true, writable: true }, + ]; + const deviceState: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'power', value: state.on, updatedAt }, + ]; + if (state.brightness !== undefined || this.supports(bulbArg, 'set_bright')) { + features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }); + deviceState.push({ featureId: 'brightness', value: state.brightness ?? null, updatedAt }); + } + if (state.colorTempKelvin !== undefined || this.supports(bulbArg, 'set_ct_abx')) { + features.push({ id: 'color_temperature', capability: 'light', name: 'Color temperature', readable: true, writable: true, unit: 'K' }); + deviceState.push({ featureId: 'color_temperature', value: state.colorTempKelvin ?? null, updatedAt }); + } + if (state.rgbColor !== undefined || this.supports(bulbArg, 'set_rgb')) { + features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true }); + deviceState.push({ featureId: 'rgb', value: state.rgbColor ? state.rgbColor.join(',') : null, updatedAt }); + } + for (const sensor of sensorProperties) { + const value = properties[sensor.key]; + if (value === undefined || value === null) { + continue; + } + const featureId = String(sensor.key); + if (features.some((featureArg) => featureArg.id === featureId)) { + continue; + } + features.push({ id: featureId, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit }); + deviceState.push({ featureId, value: this.deviceStateValue(sensor.binary ? this.binaryState(value) : this.propertyValue(value)), updatedAt }); + } + return { + id: this.deviceId(bulbArg), + integrationDomain: 'yeelight', + name: this.bulbName(bulbArg), + protocol: 'unknown', + manufacturer: 'Yeelight', + model: bulbArg.model || bulbArg.capabilities?.model, + online: bulbArg.available !== false && (snapshotArg.connected || Boolean(bulbArg.properties)), + features, + state: deviceState, + metadata: { + host: bulbArg.host, + port: bulbArg.port || 55443, + firmwareVersion: bulbArg.fwVersion || bulbArg.capabilities?.fw_ver, + support: bulbArg.support || bulbArg.capabilities?.support?.split(' ').filter(Boolean), + }, + }; + }); + } + + public static toEntities(snapshotArg: IYeelightSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const bulb of snapshotArg.bulbs) { + const deviceId = this.deviceId(bulb); + const name = this.bulbName(bulb); + const slug = this.slug(name); + const state = this.bulbState(bulb); + entities.push({ + id: `light.${slug}`, + uniqueId: `yeelight_${this.slug(bulb.id || bulb.host || name)}_light`, + integrationDomain: 'yeelight', + deviceId, + platform: 'light', + name, + state: state.on ? 'on' : 'off', + attributes: { + brightness: state.brightness, + colorTempKelvin: state.colorTempKelvin, + rgbColor: state.rgbColor, + hsColor: state.hsColor, + colorMode: state.colorMode, + flowing: state.flowing, + nightLight: state.nightLight, + host: bulb.host, + model: bulb.model || bulb.capabilities?.model, + }, + available: bulb.available !== false, + }); + for (const sensor of sensorProperties) { + const value = bulb.properties?.[sensor.key]; + if (value === undefined || value === null) { + continue; + } + const platform = sensor.binary ? 'binary_sensor' : 'sensor'; + const sensorSlug = this.slug(`${name} ${sensor.name}`); + entities.push({ + id: `${platform}.${sensorSlug}`, + uniqueId: `yeelight_${this.slug(bulb.id || bulb.host || name)}_${String(sensor.key)}`, + integrationDomain: 'yeelight', + deviceId, + platform, + name: `${name} ${sensor.name}`, + state: sensor.binary ? this.binaryState(value) ? 'on' : 'off' : this.propertyValue(value), + attributes: sensor.unit ? { unit: sensor.unit } : undefined, + available: bulb.available !== false, + }); + } + } + return entities; + } + + public static toIntegrationEvent(eventArg: IYeelightEvent): IIntegrationEvent { + return { + type: eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'yeelight', + deviceId: eventArg.bulbId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + private static bulbState(bulbArg: IYeelightBulbInfo) { + if (bulbArg.state) { + return bulbArg.state; + } + const properties = bulbArg.properties || {}; + const power = properties.main_power ?? properties.power; + const brightness = this.numberValue(properties.bright); + return { + on: power === 'on', + brightness, + colorTempKelvin: this.numberValue(properties.ct), + rgbColor: this.rgbValue(properties.rgb), + hsColor: this.hsValue(properties.hue, properties.sat), + colorMode: properties.color_mode === undefined || properties.color_mode === null ? undefined : String(properties.color_mode), + flowing: this.binaryState(properties.flowing), + nightLight: properties.active_mode !== undefined ? String(properties.active_mode) === '1' : this.numberValue(properties.nl_br) ? this.numberValue(properties.nl_br)! > 0 : undefined, + }; + } + + private static supports(bulbArg: IYeelightBulbInfo, commandArg: string): boolean { + const support = bulbArg.support || bulbArg.capabilities?.support?.split(' ').filter(Boolean) || []; + return support.includes(commandArg); + } + + private static deviceId(bulbArg: IYeelightBulbInfo): string { + return `yeelight.bulb.${this.slug(bulbArg.id || bulbArg.host || this.bulbName(bulbArg))}`; + } + + private static bulbName(bulbArg: IYeelightBulbInfo): string { + return bulbArg.name || bulbArg.properties?.name || bulbArg.capabilities?.name || bulbArg.model || bulbArg.host || 'Yeelight Bulb'; + } + + private static rgbValue(valueArg: unknown): [number, number, number] | undefined { + const numeric = this.numberValue(valueArg); + if (numeric === undefined) { + return undefined; + } + return [(numeric >> 16) & 0xff, (numeric >> 8) & 0xff, numeric & 0xff]; + } + + private static hsValue(hueArg: unknown, satArg: unknown): [number, number] | undefined { + const hue = this.numberValue(hueArg); + const sat = this.numberValue(satArg); + return hue === undefined || sat === undefined ? undefined : [hue, sat]; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private static binaryState(valueArg: unknown): boolean { + return valueArg === true || valueArg === 'on' || valueArg === '1' || valueArg === 1; + } + + private static propertyValue(valueArg: unknown): string | number | boolean | null { + const numeric = this.numberValue(valueArg); + if (numeric !== undefined) { + return numeric; + } + if (typeof valueArg === 'string' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + return String(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'yeelight'; + } +} diff --git a/ts/integrations/yeelight/yeelight.types.ts b/ts/integrations/yeelight/yeelight.types.ts index dd15ac7..c2e9ea3 100644 --- a/ts/integrations/yeelight/yeelight.types.ts +++ b/ts/integrations/yeelight/yeelight.types.ts @@ -1,4 +1,218 @@ -export interface IHomeAssistantYeelightConfig { - // TODO: replace with the TypeScript-native config for yeelight. +export type TYeelightPowerState = 'on' | 'off'; +export type TYeelightEffect = 'smooth' | 'sudden'; +export type TYeelightCommandMethod = + | 'get_prop' + | 'set_power' + | 'set_bright' + | 'set_ct_abx' + | 'set_rgb' + | 'set_hsv' + | 'set_scene' + | 'start_cf' + | 'stop_cf' + | 'set_default' + | 'set_name' + | 'toggle' + | string; + +export interface IYeelightConfig { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + detectedModel?: string; + detected_model?: string; + transition?: number; + effect?: TYeelightEffect; + snapshot?: IYeelightSnapshot; + bulb?: IYeelightBulbInfo; + bulbs?: IYeelightBulbInfo[]; + devices?: Record; + commandExecutor?: TYeelightCommandExecutor; [key: string]: unknown; } + +export interface IHomeAssistantYeelightConfig extends IYeelightConfig {} + +export interface IYeelightManualDeviceConfig { + id?: string; + name?: string; + model?: string; + detectedModel?: string; + detected_model?: string; + port?: number; + properties?: IYeelightBulbProperties; + capabilities?: IYeelightCapabilities; + [key: string]: unknown; +} + +export interface IYeelightCapabilities { + id?: string; + location?: string; + model?: string; + fw_ver?: string; + support?: string; + power?: string; + bright?: string; + color_mode?: string; + ct?: string; + rgb?: string; + hue?: string; + sat?: string; + name?: string; + [key: string]: string | undefined; +} + +export interface IYeelightBulbProperties { + power?: string | null; + main_power?: string | null; + bright?: string | null; + ct?: string | null; + rgb?: string | null; + hue?: string | null; + sat?: string | null; + color_mode?: string | null; + flowing?: string | null; + delayoff?: string | null; + music_on?: string | null; + name?: string | null; + bg_power?: string | null; + bg_flowing?: string | null; + bg_ct?: string | null; + bg_bright?: string | null; + bg_hue?: string | null; + bg_sat?: string | null; + bg_rgb?: string | null; + bg_lmode?: string | null; + nl_br?: string | null; + active_mode?: string | null; + current_brightness?: string | null; + [key: string]: string | number | boolean | null | undefined; +} + +export interface IYeelightBulbState { + on: boolean; + brightness?: number; + colorTempKelvin?: number; + rgbColor?: [number, number, number]; + hsColor?: [number, number]; + colorMode?: string; + flowing?: boolean; + nightLight?: boolean; +} + +export interface IYeelightBulbInfo { + id?: string; + host?: string; + port?: number; + name?: string; + model?: string; + fwVersion?: string; + support?: string[]; + capabilities?: IYeelightCapabilities; + properties?: IYeelightBulbProperties; + state?: IYeelightBulbState; + available?: boolean; + metadata?: Record; +} + +export interface IYeelightSnapshot { + connected: boolean; + bulbs: IYeelightBulbInfo[]; + events: IYeelightEvent[]; + source?: 'snapshot' | 'manual' | 'tcp'; + updatedAt?: string; +} + +export interface IYeelightCommand { + id?: number; + method: TYeelightCommandMethod; + params?: unknown[]; + host?: string; + port?: number; +} + +export interface IYeelightCommandResponse { + id?: number; + result?: unknown[]; + error?: unknown; + method?: string; + params?: Record; +} + +export interface IYeelightCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IYeelightLightStatePatch { + on?: boolean; + brightness?: number; + percentage?: number; + colorTempKelvin?: number; + rgbColor?: [number, number, number]; + transition?: number; + effect?: TYeelightEffect; +} + +export type TYeelightCommandExecutor = ( + commandArg: IYeelightCommand +) => Promise | IYeelightCommandResponse | IYeelightCommandResult | unknown; + +export interface IYeelightEvent { + type: 'properties' | 'command' | 'error'; + timestamp?: number; + bulbId?: string; + host?: string; + entityId?: string; + command?: IYeelightCommand; + data?: unknown; + error?: string; + [key: string]: unknown; +} + +export interface IYeelightSsdpRecord { + location?: string; + Location?: string; + id?: string; + Id?: string; + model?: string; + Model?: string; + support?: string; + Support?: string; + st?: string; + ST?: string; + usn?: string; + USN?: string; + server?: string; + Server?: string; + ssdp_headers?: Record; + headers?: Record; + [key: string]: unknown; +} + +export interface IYeelightMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface IYeelightManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + properties?: IYeelightBulbProperties; + capabilities?: IYeelightCapabilities; + snapshot?: IYeelightSnapshot; + metadata?: Record; +} diff --git a/ts/integrations/zha/.generated-by-smarthome-exchange b/ts/integrations/zha/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/zha/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/zha/index.ts b/ts/integrations/zha/index.ts index f1014c9..8af6cc6 100644 --- a/ts/integrations/zha/index.ts +++ b/ts/integrations/zha/index.ts @@ -1,2 +1,6 @@ export * from './zha.classes.integration.js'; +export * from './zha.classes.client.js'; +export * from './zha.classes.configflow.js'; +export * from './zha.discovery.js'; +export * from './zha.mapper.js'; export * from './zha.types.js'; diff --git a/ts/integrations/zha/zha.classes.client.ts b/ts/integrations/zha/zha.classes.client.ts new file mode 100644 index 0000000..7f876a8 --- /dev/null +++ b/ts/integrations/zha/zha.classes.client.ts @@ -0,0 +1,77 @@ +import type { IZhaClientCommand, IZhaCommandResult, IZhaConfig, IZhaEvent, IZhaSnapshot } from './zha.types.js'; +import { ZhaMapper } from './zha.mapper.js'; + +type TZhaEventHandler = (eventArg: IZhaEvent) => void; + +export class ZhaClient { + private readonly events: IZhaEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IZhaConfig) {} + + public async getSnapshot(): Promise { + return ZhaMapper.toSnapshot(this.config, undefined, this.events); + } + + public onEvent(handlerArg: TZhaEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IZhaClientCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, entityId: commandArg.entityId, uniqueId: commandArg.uniqueId, timestamp: Date.now() }); + if (this.config.commandExecutor) { + const result = await this.config.commandExecutor(commandArg); + return this.commandResult(result, commandArg); + } + return { + success: false, + error: 'ZHA live Zigbee radio writes require the zha/zigpy radio stack and serial protocol support, which is not implemented in this dependency-free TypeScript port. The mapped command was not sent.', + data: { command: commandArg }, + }; + } + + public async connectLive(): Promise { + await new ZhaZigpyRadioConnection(this.config).connect(); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private emit(eventArg: IZhaEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IZhaClientCommand): IZhaCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IZhaCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } +} + +export class ZhaZigpyRadioConnection { + constructor(private readonly config: IZhaConfig) {} + + public async connect(): Promise { + throw new Error(this.unsupportedMessage()); + } + + public async sendCommand(commandArg: IZhaClientCommand): Promise { + void commandArg; + throw new Error(this.unsupportedMessage()); + } + + private unsupportedMessage(): string { + const radioPath = this.config.radio?.path || this.config.device?.path || this.config.usbPath || this.config.usb_path || 'configured radio'; + return `ZHA live radio control for ${radioPath} requires zha/zigpy plus radio-specific serial framing. This TypeScript port intentionally does not guess those internals.`; + } +} diff --git a/ts/integrations/zha/zha.classes.configflow.ts b/ts/integrations/zha/zha.classes.configflow.ts new file mode 100644 index 0000000..0ffa57e --- /dev/null +++ b/ts/integrations/zha/zha.classes.configflow.ts @@ -0,0 +1,121 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IZhaConfig, IZhaSnapshot, TZhaFlowControl, TZhaRadioType } from './zha.types.js'; + +const radioTypeOptions: Array<{ label: string; value: string }> = [ + { label: 'Silicon Labs EZSP / EmberZNet', value: 'ezsp' }, + { label: 'TI Z-Stack (ZNP)', value: 'znp' }, + { label: 'deCONZ / ConBee', value: 'deconz' }, + { label: 'XBee', value: 'xbee' }, + { label: 'ZiGate', value: 'zigate' }, +]; + +export class ZhaConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaults = this.defaultsFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Connect Zigbee Home Automation', + description: 'Configure the ZHA radio path and optional snapshot data. Live Zigbee radio probing is not performed by this TypeScript port.', + fields: [ + { name: 'radioPath', label: 'Radio path or socket URL', type: 'text', required: true }, + { name: 'radioType', label: 'Radio type', type: 'select', required: true, options: radioTypeOptions }, + { name: 'baudrate', label: 'Baudrate', type: 'number' }, + { name: 'flowControl', label: 'Flow control', type: 'select', options: [{ label: 'None', value: 'none' }, { label: 'Hardware', value: 'hardware' }, { label: 'Software', value: 'software' }] }, + { name: 'databasePath', label: 'Zigpy database path', type: 'text' }, + { name: 'enableQuirks', label: 'Enable ZHA quirks', type: 'boolean' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => { + const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot); + if (snapshot === false) { + return { kind: 'error', title: 'Invalid ZHA snapshot', error: 'Snapshot JSON must be a JSON object.' }; + } + const radioPath = this.stringValue(valuesArg.radioPath) || defaults.radioPath || '/dev/ttyUSB0'; + const radioType = this.stringValue(valuesArg.radioType) || defaults.radioType || 'znp'; + const baudrate = this.numberValue(valuesArg.baudrate) || defaults.baudrate || 115200; + const flowControl = this.flowControlValue(valuesArg.flowControl) ?? defaults.flowControl ?? 'none'; + return { + kind: 'done', + title: 'ZHA configured', + config: { + radio: { + path: radioPath, + radioType, + baudrate, + flowControl, + socketUrl: radioPath.startsWith('socket://') ? radioPath : undefined, + }, + device: { + path: radioPath, + baudrate, + flow_control: flowControl === 'none' ? null : flowControl, + }, + radioType, + radio_type: radioType, + databasePath: this.stringValue(valuesArg.databasePath) || defaults.databasePath, + enableQuirks: this.booleanValue(valuesArg.enableQuirks) ?? true, + snapshot: snapshot || undefined, + }, + }; + }, + }; + } + + private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { radioPath?: string; radioType?: TZhaRadioType; baudrate?: number; flowControl?: TZhaFlowControl; databasePath?: string; snapshot?: IZhaSnapshot } { + const metadata = candidateArg.metadata || {}; + return { + radioPath: this.stringValue(metadata.radioPath) || this.stringValue(metadata.socketUrl) || this.stringValue(metadata.path) || (candidateArg.host ? `socket://${candidateArg.host}:${candidateArg.port || 6638}` : undefined), + radioType: this.stringValue(metadata.radioType), + baudrate: this.numberValue(metadata.baudrate), + flowControl: this.flowControlValue(metadata.flowControl), + databasePath: this.stringValue(metadata.databasePath), + snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined, + }; + } + + private snapshotValue(valueArg: unknown, fallbackArg?: IZhaSnapshot): IZhaSnapshot | undefined | false { + if (valueArg === undefined || valueArg === null || valueArg === '') { + return fallbackArg; + } + if (this.isSnapshot(valueArg)) { + return valueArg; + } + if (typeof valueArg !== 'string') { + return false; + } + try { + const parsed = JSON.parse(valueArg) as unknown; + return this.isRecord(parsed) ? parsed as unknown as IZhaSnapshot : false; + } catch { + return false; + } + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private flowControlValue(valueArg: unknown): TZhaFlowControl | undefined { + if (valueArg === null || valueArg === 'none' || valueArg === 'hardware' || valueArg === 'software') { + return valueArg; + } + return undefined; + } + + private isSnapshot(valueArg: unknown): valueArg is IZhaSnapshot { + return this.isRecord(valueArg); + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/zha/zha.classes.integration.ts b/ts/integrations/zha/zha.classes.integration.ts index 4c10080..2285668 100644 --- a/ts/integrations/zha/zha.classes.integration.ts +++ b/ts/integrations/zha/zha.classes.integration.ts @@ -1,36 +1,129 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { ZhaClient } from './zha.classes.client.js'; +import { ZhaConfigFlow } from './zha.classes.configflow.js'; +import { createZhaDiscoveryDescriptor } from './zha.discovery.js'; +import { ZhaMapper } from './zha.mapper.js'; +import type { IZhaClientCommand, IZhaConfig } from './zha.types.js'; -export class HomeAssistantZhaIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "zha", - displayName: "Zigbee Home Automation", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/zha", - "upstreamDomain": "zha", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "zha==1.3.0" - ], - "dependencies": [ - "file_upload", - "homeassistant_hardware" - ], - "afterDependencies": [ - "hassio", - "onboarding", - "usb" - ], - "codeowners": [ - "@dmulcahey", - "@adminiuga", - "@puddly", - "@TheJulianJES" - ] -}, +export class ZhaIntegration extends BaseIntegration { + public readonly domain = 'zha'; + public readonly displayName = 'Zigbee Home Automation'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createZhaDiscoveryDescriptor(); + public readonly configFlow = new ZhaConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/zha', + upstreamDomain: 'zha', + integrationType: 'hub', + iotClass: 'local_polling', + qualityScale: 'unknown', + }; + + public async setup(configArg: IZhaConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new ZhaRuntime(new ZhaClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantZhaIntegration extends ZhaIntegration {} + +class ZhaRuntime implements IIntegrationRuntime { + public domain = 'zha'; + + constructor(private readonly client: ZhaClient) {} + + public async devices(): Promise { + return ZhaMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return ZhaMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg({ + type: eventArg.type === 'device_removed' ? 'device_removed' : eventArg.type === 'availability_changed' ? 'availability_changed' : 'state_changed', + integrationDomain: 'zha', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = requestArg.domain === 'zha' + ? this.commandFromService(requestArg) + : ZhaMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported ZHA service: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromService(requestArg: IServiceCallRequest): IZhaClientCommand | undefined { + if (requestArg.service === 'issue_zigbee_cluster_command') { + return { + type: 'cluster.command', + service: requestArg.service, + ieee: this.stringValue(requestArg.data?.ieee), + endpointId: this.numberValue(requestArg.data?.endpoint_id ?? requestArg.data?.endpointId), + clusterId: this.numberValue(requestArg.data?.cluster_id ?? requestArg.data?.clusterId), + clusterType: this.stringValue(requestArg.data?.cluster_type ?? requestArg.data?.clusterType), + command: requestArg.data?.command as string | number | undefined, + args: Array.isArray(requestArg.data?.args) ? requestArg.data.args : undefined, + params: this.isRecord(requestArg.data?.params) ? requestArg.data.params : undefined, + payload: { ...requestArg.data }, + target: requestArg.target, + }; + } + if (requestArg.service === 'set_zigbee_cluster_attribute') { + return { + type: 'cluster.write_attribute', + service: requestArg.service, + ieee: this.stringValue(requestArg.data?.ieee), + endpointId: this.numberValue(requestArg.data?.endpoint_id ?? requestArg.data?.endpointId), + clusterId: this.numberValue(requestArg.data?.cluster_id ?? requestArg.data?.clusterId), + clusterType: this.stringValue(requestArg.data?.cluster_type ?? requestArg.data?.clusterType), + attribute: requestArg.data?.attribute as string | number | undefined, + value: requestArg.data?.value, + payload: { ...requestArg.data }, + target: requestArg.target, + }; + } + if (requestArg.service === 'permit') { + return { + type: 'network.permit', + service: requestArg.service, + payload: { duration: requestArg.data?.duration ?? 60 }, + target: requestArg.target, + }; + } + return undefined; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' ? valueArg : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); } } diff --git a/ts/integrations/zha/zha.discovery.ts b/ts/integrations/zha/zha.discovery.ts new file mode 100644 index 0000000..ff43d37 --- /dev/null +++ b/ts/integrations/zha/zha.discovery.ts @@ -0,0 +1,322 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IZhaManualEntry, IZhaMdnsRecord, IZhaUsbRecord, TZhaRadioType } from './zha.types.js'; + +interface IZhaKnownUsbDevice { + vid: string; + pid: string; + description?: string; + knownDevices?: string[]; + radioType?: TZhaRadioType; +} + +const zhaZeroconfTypes = new Set([ + '_zigbee-coordinator._tcp.local', + '_zigate-zigbee-gateway._tcp.local', + '_zigstar_gw._tcp.local', + '_uzg-01._tcp.local', + '_slzb-06._tcp.local', + '_xzg._tcp.local', + '_czc._tcp.local', +]); + +const legacyZeroconfTypes = new Set(['_esphomelib._tcp.local']); +const defaultZigbeeCoordinatorPort = 6638; + +const haManifestUsbDevices: IZhaKnownUsbDevice[] = [ + { vid: '10c4', pid: 'ea60', description: '*2652*', knownDevices: ['slae.sh cc2652rb stick'], radioType: 'znp' }, + { vid: '10c4', pid: 'ea60', description: '*slzb-07*', knownDevices: ['smlight slzb-07'], radioType: 'znp' }, + { vid: '1a86', pid: '55d4', description: '*sonoff*plus*', knownDevices: ['sonoff zigbee dongle plus v2'], radioType: 'ezsp' }, + { vid: '10c4', pid: 'ea60', description: '*sonoff*plus*', knownDevices: ['sonoff zigbee dongle plus'], radioType: 'znp' }, + { vid: '10c4', pid: 'ea60', description: '*tubeszb*', knownDevices: ['TubesZB Coordinator'], radioType: 'znp' }, + { vid: '1a86', pid: '7523', description: '*tubeszb*', knownDevices: ['TubesZB Coordinator'], radioType: 'znp' }, + { vid: '1a86', pid: '7523', description: '*zigstar*', knownDevices: ['ZigStar Coordinators'], radioType: 'znp' }, + { vid: '1cf1', pid: '0030', description: '*conbee*', knownDevices: ['Conbee II'], radioType: 'deconz' }, + { vid: '0403', pid: '6015', description: '*conbee*', knownDevices: ['Conbee III'], radioType: 'deconz' }, + { vid: '10c4', pid: '8a2a', description: '*zigbee*', knownDevices: ['Nortek HUSBZB-1'], radioType: 'ezsp' }, + { vid: '0403', pid: '6015', description: '*zigate*', knownDevices: ['ZiGate+'], radioType: 'zigate' }, + { vid: '10c4', pid: 'ea60', description: '*zigate*', knownDevices: ['ZiGate'], radioType: 'zigate' }, + { vid: '10c4', pid: '8b34', description: '*bv 2010/10*', knownDevices: ['Bitron Video AV2010/10'] }, + { vid: '10c4', pid: 'ea60', description: '*sonoff*max*', knownDevices: ['SONOFF Dongle Max MG24'], radioType: 'ezsp' }, + { vid: '10c4', pid: 'ea60', description: '*sonoff*lite*mg21*', knownDevices: ['sonoff zigbee dongle lite mg21'], radioType: 'ezsp' }, +]; + +const commonZigbeeUsbIds = new Set(haManifestUsbDevices.map((entryArg) => `${entryArg.vid}:${entryArg.pid}`)); + +const zigbeeTextHints = [ + 'zigbee', + 'conbee', + 'sonoff', + 'zigate', + 'zigstar', + 'tubeszb', + 'slzb', + 'cc2652', + 'cc1352', + 'efr32', + 'ember', + 'husbzb', + 'skyconnect', + 'zbt', +]; + +export class ZhaUsbMatcher implements IDiscoveryMatcher { + public id = 'zha-usb-match'; + public source = 'usb' as const; + public description = 'Recognize known USB Zigbee coordinators supported by ZHA.'; + + public async matches(recordArg: IZhaUsbRecord): Promise { + const vid = this.normalizeUsbId(recordArg.vid); + const pid = this.normalizeUsbId(recordArg.pid); + const usbId = `${vid}:${pid}`; + const path = recordArg.path || recordArg.device; + const serialNumber = recordArg.serialNumber || recordArg.serial_number; + const text = this.recordText(recordArg); + const haMatch = haManifestUsbDevices.find((entryArg) => entryArg.vid === vid && entryArg.pid === pid && this.matchesManifestEntry(entryArg, text)); + const idMatch = commonZigbeeUsbIds.has(usbId); + const textMatch = zigbeeTextHints.some((hintArg) => text.includes(hintArg)); + const matched = Boolean(haMatch || idMatch || textMatch); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'USB device is not a known ZHA Zigbee coordinator.' }; + } + const radioType = haMatch?.radioType || this.guessRadioType(text, usbId); + return { + matched: true, + confidence: haMatch ? 'certain' : idMatch ? 'high' : 'medium', + reason: haMatch ? 'USB record matches Home Assistant ZHA manifest USB metadata.' : 'USB record matches common Zigbee coordinator metadata.', + normalizedDeviceId: serialNumber || path || usbId, + candidate: { + source: 'usb', + integrationDomain: 'zha', + id: serialNumber || path || usbId, + name: recordArg.description || recordArg.product || 'Zigbee coordinator', + manufacturer: recordArg.manufacturer || this.manufacturerFromText(text), + model: recordArg.description || recordArg.product || haMatch?.knownDevices?.[0], + serialNumber, + metadata: { + vid: recordArg.vid, + pid: recordArg.pid, + path, + radioPath: path, + serialNumber, + radioType, + baudrate: 115200, + flowControl: 'none', + haUsb: haMatch, + }, + }, + }; + } + + private normalizeUsbId(valueArg?: string): string { + return (valueArg || '').replace(/^0x/i, '').toLowerCase().padStart(4, '0'); + } + + private recordText(recordArg: IZhaUsbRecord): string { + return [recordArg.manufacturer, recordArg.description, recordArg.product, recordArg.serialNumber, recordArg.serial_number] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + } + + private matchesManifestEntry(entryArg: IZhaKnownUsbDevice, textArg: string): boolean { + if (!entryArg.description && !entryArg.knownDevices?.length) { + return true; + } + const descriptionMatch = entryArg.description ? this.wildcardMatch(entryArg.description, textArg) : false; + const knownDeviceMatch = (entryArg.knownDevices || []).some((deviceArg) => textArg.includes(deviceArg.toLowerCase())); + return descriptionMatch || knownDeviceMatch; + } + + private wildcardMatch(patternArg: string, valueArg: string): boolean { + const parts = patternArg.toLowerCase().split('*').filter(Boolean); + return parts.every((partArg) => valueArg.includes(partArg)); + } + + private guessRadioType(textArg: string, usbIdArg: string): TZhaRadioType | undefined { + if (textArg.includes('conbee')) { + return 'deconz'; + } + if (textArg.includes('zigate')) { + return 'zigate'; + } + if (textArg.includes('xbee')) { + return 'xbee'; + } + if (textArg.includes('sonoff') && (textArg.includes('v2') || textArg.includes('mg21') || textArg.includes('mg24') || usbIdArg === '1a86:55d4')) { + return 'ezsp'; + } + if (textArg.includes('efr32') || textArg.includes('ember') || textArg.includes('skyconnect') || textArg.includes('zbt')) { + return 'ezsp'; + } + if (textArg.includes('cc2652') || textArg.includes('cc1352') || textArg.includes('zigstar') || textArg.includes('tubeszb') || textArg.includes('slzb')) { + return 'znp'; + } + return undefined; + } + + private manufacturerFromText(textArg: string): string { + if (textArg.includes('sonoff')) { + return 'SONOFF'; + } + if (textArg.includes('conbee')) { + return 'dresden elektronik'; + } + if (textArg.includes('zigate')) { + return 'ZiGate'; + } + return 'Zigbee'; + } +} + +export class ZhaMdnsMatcher implements IDiscoveryMatcher { + public id = 'zha-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize network-attached Zigbee coordinators advertised for ZHA.'; + + public async matches(recordArg: IZhaMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const txt = recordArg.txt || recordArg.properties || {}; + const text = [recordArg.name, recordArg.hostname, this.txt(txt, 'name'), this.txt(txt, 'radio_type')].filter(Boolean).join(' ').toLowerCase(); + const isNativeType = zhaZeroconfTypes.has(type); + const isLegacyType = legacyZeroconfTypes.has(type) && (text.includes('tube') || text.includes('zigbee')); + const matched = isNativeType || isLegacyType || Boolean(this.txt(txt, 'radio_type') && this.txt(txt, 'serial_number')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a ZHA Zigbee coordinator advertisement.' }; + } + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const port = recordArg.port || defaultZigbeeCoordinatorPort; + const radioType = this.txt(txt, 'radio_type') || this.guessRadioType(text, type); + const serialNumber = this.txt(txt, 'serial_number') || recordArg.hostname?.replace(/\.local\.?$/i, ''); + return { + matched: true, + confidence: radioType && host ? 'certain' : host ? 'high' : 'medium', + reason: 'mDNS record matches ZHA network coordinator metadata.', + normalizedDeviceId: serialNumber || recordArg.name || host, + candidate: { + source: 'mdns', + integrationDomain: 'zha', + id: serialNumber || recordArg.name || host, + host, + port, + name: this.deviceName(recordArg, txt) || 'Zigbee coordinator', + manufacturer: 'Zigbee', + model: 'Network Zigbee coordinator', + serialNumber, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt, + radioType, + radioPath: host ? `socket://${host}:${port}` : undefined, + socketUrl: host ? `socket://${host}:${port}` : undefined, + baudrate: 115200, + flowControl: 'none', + }, + }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private deviceName(recordArg: IZhaMdnsRecord, txtArg: Record): string | undefined { + return this.txt(txtArg, 'name') || recordArg.name?.split('._', 1)[0] || recordArg.hostname?.replace(/\.local\.?$/i, ''); + } + + private guessRadioType(textArg: string, typeArg: string): TZhaRadioType | undefined { + if (textArg.includes('zigate') || typeArg.includes('zigate')) { + return 'zigate'; + } + if (textArg.includes('efr32') || textArg.includes('ezsp')) { + return 'ezsp'; + } + if (textArg.includes('deconz') || textArg.includes('conbee')) { + return 'deconz'; + } + if (textArg.includes('znp') || textArg.includes('zigstar') || textArg.includes('slzb') || textArg.includes('uzg') || textArg.includes('xzg')) { + return 'znp'; + } + return undefined; + } +} + +export class ZhaManualMatcher implements IDiscoveryMatcher { + public id = 'zha-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual ZHA radio setup entries.'; + + public async matches(inputArg: IZhaManualEntry): Promise { + const radioPath = inputArg.radioPath || inputArg.devicePath || inputArg.usbPath || inputArg.path || inputArg.socketUrl || (inputArg.host ? `socket://${inputArg.host}:${inputArg.port || defaultZigbeeCoordinatorPort}` : undefined); + const model = inputArg.model?.toLowerCase() || ''; + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const matched = Boolean(radioPath || inputArg.radioType || inputArg.metadata?.zha || inputArg.metadata?.zigbee || model.includes('zigbee') || manufacturer.includes('zigbee')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ZHA radio setup hints.' }; + } + return { + matched: true, + confidence: radioPath && inputArg.radioType ? 'high' : 'medium', + reason: 'Manual entry can start ZHA radio setup.', + normalizedDeviceId: inputArg.id || radioPath, + candidate: { + source: 'manual', + integrationDomain: 'zha', + id: inputArg.id || radioPath, + host: inputArg.host, + port: inputArg.port, + name: inputArg.name || 'Zigbee Home Automation', + manufacturer: inputArg.manufacturer || 'Zigbee', + model: inputArg.model || 'ZHA radio', + metadata: { + ...inputArg.metadata, + radioPath, + socketUrl: radioPath?.startsWith('socket://') ? radioPath : undefined, + radioType: inputArg.radioType, + baudrate: inputArg.baudrate, + flowControl: inputArg.flowControl, + snapshot: inputArg.snapshot, + }, + }, + }; + } +} + +export class ZhaCandidateValidator implements IDiscoveryValidator { + public id = 'zha-candidate-validator'; + public description = 'Validate ZHA radio discovery candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const model = candidateArg.model?.toLowerCase() || ''; + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase().replace(/\.$/, '') : ''; + const vid = typeof metadata.vid === 'string' ? metadata.vid.replace(/^0x/i, '').toLowerCase().padStart(4, '0') : ''; + const pid = typeof metadata.pid === 'string' ? metadata.pid.replace(/^0x/i, '').toLowerCase().padStart(4, '0') : ''; + const usbRecognized = Boolean(vid && pid && commonZigbeeUsbIds.has(`${vid}:${pid}`)); + const hasRadioPath = typeof metadata.radioPath === 'string' || typeof metadata.socketUrl === 'string'; + const textMatched = zigbeeTextHints.some((hintArg) => model.includes(hintArg) || manufacturer.includes(hintArg)); + const matched = candidateArg.integrationDomain === 'zha' || usbRecognized || zhaZeroconfTypes.has(mdnsType) || Boolean(metadata.radioType || metadata.zha || metadata.zigbee || hasRadioPath && textMatched || textMatched); + return { + matched, + confidence: matched && candidateArg.integrationDomain === 'zha' && (hasRadioPath || metadata.radioType) ? 'certain' : matched && (usbRecognized || hasRadioPath) ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has ZHA radio metadata.' : 'Candidate is not ZHA.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.serialNumber || candidateArg.id || candidateArg.host, + metadata: matched ? { usbRecognized, hasRadioPath, radioType: metadata.radioType } : undefined, + }; + } +} + +export const createZhaDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'zha', displayName: 'Zigbee Home Automation' }) + .addMatcher(new ZhaUsbMatcher()) + .addMatcher(new ZhaMdnsMatcher()) + .addMatcher(new ZhaManualMatcher()) + .addValidator(new ZhaCandidateValidator()); +}; diff --git a/ts/integrations/zha/zha.mapper.ts b/ts/integrations/zha/zha.mapper.ts new file mode 100644 index 0000000..1eb574c --- /dev/null +++ b/ts/integrations/zha/zha.mapper.ts @@ -0,0 +1,310 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IZhaClientCommand, IZhaConfig, IZhaDevice, IZhaEntityDescriptor, IZhaSnapshot, TZhaEntityPlatform } from './zha.types.js'; + +export class ZhaMapper { + public static toSnapshot(configArg: IZhaConfig, connectedArg?: boolean, eventsArg: IZhaSnapshot['events'] = []): IZhaSnapshot { + const source = configArg.snapshot; + return { + connected: connectedArg ?? source?.connected ?? false, + radio: configArg.radio || source?.radio || { + path: configArg.usbPath || configArg.usb_path || configArg.device?.path, + radioType: configArg.radioType || configArg.radio_type, + baudrate: configArg.device?.baudrate, + flowControl: configArg.device?.flowControl || configArg.device?.flow_control, + }, + network: configArg.network || source?.network, + coordinator: configArg.coordinator || source?.coordinator, + devices: [...(source?.devices || []), ...(configArg.devices || [])], + entities: [...(source?.entities || []), ...(configArg.entities || [])], + groups: [...(source?.groups || []), ...(configArg.groups || [])], + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + }; + } + + public static toDevices(snapshotArg: IZhaSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const devices = new Map(); + const coordinatorId = this.coordinatorDeviceId(snapshotArg); + devices.set(coordinatorId, { + id: coordinatorId, + integrationDomain: 'zha', + name: 'ZHA coordinator', + protocol: 'zigbee', + manufacturer: snapshotArg.coordinator?.manufacturer || 'Zigbee', + model: snapshotArg.coordinator?.model || String(snapshotArg.radio?.radioType || 'Coordinator'), + online: snapshotArg.connected || Boolean(snapshotArg.devices.length || snapshotArg.entities.length), + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt }, + { featureId: 'device_count', value: snapshotArg.devices.length, updatedAt }, + ], + metadata: { + radio: snapshotArg.radio, + network: snapshotArg.network, + coordinator: snapshotArg.coordinator, + }, + }); + + for (const device of snapshotArg.devices) { + const deviceId = this.deviceId(device); + devices.set(deviceId, { + id: deviceId, + integrationDomain: 'zha', + name: this.deviceName(device), + protocol: 'zigbee', + manufacturer: device.manufacturer, + model: device.model, + online: device.available !== false, + features: [ + { id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }, + ], + state: [ + { featureId: 'availability', value: device.available === false ? 'offline' : 'online', updatedAt }, + ], + metadata: { + ieee: device.ieee, + nwk: device.nwk, + lqi: device.lqi, + rssi: device.rssi, + powerSource: device.powerSource || device.power_source, + quirkApplied: device.quirkApplied ?? device.quirk_applied, + quirkClass: device.quirkClass || device.quirk_class, + }, + }); + } + + for (const entity of this.allEntities(snapshotArg)) { + const deviceId = this.entityDeviceId(snapshotArg, entity); + const device = devices.get(deviceId) || devices.get(coordinatorId); + if (!device) { + continue; + } + const platform = this.corePlatform(entity.platform || 'sensor'); + const feature = { + id: this.slug(entity.uniqueId || entity.unique_id || entity.entityId || entity.entity_id || this.entityName(entity)), + capability: this.capabilityForPlatform(String(platform)), + name: this.entityName(entity), + readable: true, + writable: this.entityWritable(entity, platform), + unit: entity.unit || entity.unitOfMeasurement || entity.unit_of_measurement, + } satisfies plugins.shxInterfaces.data.IDeviceFeature; + device.features.push(feature); + device.state.push({ featureId: feature.id, value: this.deviceStateValue(this.entityState(entity, platform)), updatedAt }); + } + + return [...devices.values()]; + } + + public static toEntities(snapshotArg: IZhaSnapshot): IIntegrationEntity[] { + return this.allEntities(snapshotArg).map((entityArg) => { + const platform = this.corePlatform(entityArg.platform || 'sensor'); + return { + id: entityArg.entityId || entityArg.entity_id || `${platform}.${this.slug(this.entityName(entityArg))}`, + uniqueId: entityArg.uniqueId || entityArg.unique_id || `zha_${this.slug(`${entityArg.ieee || entityArg.deviceIeee || entityArg.device_id || 'device'}_${entityArg.endpointId ?? entityArg.endpoint_id ?? 0}_${entityArg.clusterId ?? entityArg.cluster_id ?? 'entity'}_${this.entityName(entityArg)}`)}`, + integrationDomain: 'zha', + deviceId: this.entityDeviceId(snapshotArg, entityArg), + platform, + name: this.entityName(entityArg), + state: this.entityState(entityArg, platform), + attributes: { + zhaPlatform: entityArg.platform, + ieee: entityArg.ieee || entityArg.deviceIeee, + endpointId: entityArg.endpointId ?? entityArg.endpoint_id, + clusterId: entityArg.clusterId ?? entityArg.cluster_id, + clusterType: entityArg.clusterType || entityArg.cluster_type, + deviceClass: entityArg.deviceClass || entityArg.device_class, + unit: entityArg.unit || entityArg.unitOfMeasurement || entityArg.unit_of_measurement, + writable: this.entityWritable(entityArg, platform), + raw: entityArg.raw || entityArg.infoObject || entityArg.info_object, + }, + available: entityArg.available !== false, + }; + }); + } + + public static commandForService(snapshotArg: IZhaSnapshot, requestArg: IServiceCallRequest): IZhaClientCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target) { + return undefined; + } + const platform = this.corePlatform(target.platform || 'sensor'); + const payload = this.payloadForService(target, requestArg, platform); + if (!payload) { + return undefined; + } + return { + type: `${platform}.command`, + service: requestArg.service, + platform: target.platform, + ieee: target.ieee || target.deviceIeee, + endpointId: target.endpointId ?? target.endpoint_id, + clusterId: target.clusterId ?? target.cluster_id, + clusterType: target.clusterType || target.cluster_type, + entityId: target.entityId || target.entity_id, + uniqueId: target.uniqueId || target.unique_id, + payload, + target: requestArg.target, + }; + } + + public static corePlatform(platformArg: TZhaEntityPlatform): TEntityPlatform { + const platform = String(platformArg).toLowerCase(); + if (platform === 'lock') { + return 'switch'; + } + if (platform === 'alarm_control_panel' || platform === 'device_tracker' || platform === 'siren' || platform === 'valve') { + return 'sensor'; + } + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + private static allEntities(snapshotArg: IZhaSnapshot): IZhaEntityDescriptor[] { + const entities = [...snapshotArg.entities]; + for (const device of snapshotArg.devices) { + for (const entity of device.entities || []) { + entities.push({ ...entity, ieee: entity.ieee || device.ieee, deviceIeee: entity.deviceIeee || device.ieee }); + } + } + for (const group of snapshotArg.groups) { + for (const entity of group.entities || []) { + entities.push({ ...entity, deviceId: entity.deviceId || entity.device_id || `zha.group.${group.groupId ?? group.group_id}`, name: entity.name || group.name }); + } + } + return entities; + } + + private static findTargetEntity(snapshotArg: IZhaSnapshot, requestArg: IServiceCallRequest): IZhaEntityDescriptor | undefined { + const entities = this.allEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.entityId === requestArg.target.entityId || entityArg.entity_id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId || entityArg.unique_id === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => this.entityDeviceId(snapshotArg, entityArg) === requestArg.target.deviceId && this.entityWritable(entityArg, this.corePlatform(entityArg.platform || 'sensor'))) + || entities.find((entityArg) => this.entityDeviceId(snapshotArg, entityArg) === requestArg.target.deviceId); + } + return entities.find((entityArg) => this.entityWritable(entityArg, this.corePlatform(entityArg.platform || 'sensor'))); + } + + private static payloadForService(entityArg: IZhaEntityDescriptor, requestArg: IServiceCallRequest, platformArg: TEntityPlatform): Record | undefined { + if (requestArg.service === 'turn_on') { + return { command: 'on', value: true }; + } + if (requestArg.service === 'turn_off') { + return { command: 'off', value: false }; + } + if (requestArg.service === 'press') { + return { command: 'press', value: true }; + } + if (requestArg.service === 'set_value') { + return { command: 'write_attribute', value: requestArg.data?.value }; + } + if (requestArg.service === 'set_position' || requestArg.service === 'set_percentage') { + return { command: 'go_to_lift_percentage', value: requestArg.data?.position ?? requestArg.data?.percentage }; + } + if (requestArg.service === 'open_cover') { + return { command: 'up_open', value: 100 }; + } + if (requestArg.service === 'close_cover') { + return { command: 'down_close', value: 0 }; + } + if (requestArg.service === 'lock' || requestArg.service === 'unlock') { + return { command: requestArg.service, value: requestArg.service === 'lock', code: requestArg.data?.code }; + } + if (platformArg === 'number' && requestArg.data?.value !== undefined) { + return { command: 'write_attribute', value: requestArg.data.value }; + } + void entityArg; + return undefined; + } + + private static entityDeviceId(snapshotArg: IZhaSnapshot, entityArg: IZhaEntityDescriptor): string { + const explicit = entityArg.deviceId || entityArg.device_id; + if (explicit) { + return explicit; + } + const ieee = entityArg.ieee || entityArg.deviceIeee; + if (ieee) { + return `zha.device.${this.slug(ieee)}`; + } + const device = snapshotArg.devices.find((deviceArg) => entityArg.deviceIeee && deviceArg.ieee === entityArg.deviceIeee); + return device ? this.deviceId(device) : this.coordinatorDeviceId(snapshotArg); + } + + private static deviceId(deviceArg: IZhaDevice): string { + return `zha.device.${this.slug(deviceArg.ieee || String(deviceArg.nwk ?? this.deviceName(deviceArg)))}`; + } + + private static coordinatorDeviceId(snapshotArg: IZhaSnapshot): string { + return `zha.coordinator.${this.slug(snapshotArg.coordinator?.ieee || snapshotArg.radio?.serialNumber || snapshotArg.radio?.path || 'network')}`; + } + + private static deviceName(deviceArg: IZhaDevice): string { + return deviceArg.name || deviceArg.model || deviceArg.ieee || 'Zigbee device'; + } + + private static entityName(entityArg: IZhaEntityDescriptor): string { + return entityArg.name || entityArg.originalName || entityArg.original_name || entityArg.fallbackName || entityArg.fallback_name || entityArg.entityId || entityArg.entity_id || 'ZHA entity'; + } + + private static entityState(entityArg: IZhaEntityDescriptor, platformArg: TEntityPlatform): unknown { + const rawValue = entityArg.state ?? entityArg.nativeValue ?? entityArg.native_value ?? entityArg.value; + if (platformArg === 'light' || platformArg === 'switch' || platformArg === 'fan') { + const value = entityArg.isOn ?? entityArg.is_on ?? rawValue; + return typeof value === 'boolean' ? value ? 'on' : 'off' : value ?? 'unknown'; + } + if (platformArg === 'cover') { + if (entityArg.currentCoverPosition ?? entityArg.current_cover_position) { + return entityArg.currentCoverPosition ?? entityArg.current_cover_position; + } + const closed = entityArg.isClosed ?? entityArg.is_closed; + return typeof closed === 'boolean' ? closed ? 'closed' : 'open' : rawValue ?? 'unknown'; + } + if (platformArg === 'climate') { + return entityArg.hvacMode || entityArg.hvac_mode || rawValue || 'unknown'; + } + return rawValue ?? 'unknown'; + } + + private static entityWritable(entityArg: IZhaEntityDescriptor, platformArg: TEntityPlatform): boolean { + if (entityArg.attributes?.writable === true || entityArg.attributes?.writeable === true) { + return true; + } + return ['light', 'switch', 'button', 'cover', 'fan', 'number', 'select', 'climate'].includes(platformArg) || String(entityArg.platform).toLowerCase() === 'lock'; + } + + private static capabilityForPlatform(platformArg: string): plugins.shxInterfaces.data.TDeviceCapability { + if (platformArg === 'light') { + return 'light'; + } + if (platformArg === 'cover') { + return 'cover'; + } + if (platformArg === 'climate') { + return 'climate'; + } + if (platformArg === 'fan') { + return 'fan'; + } + return platformArg === 'switch' || platformArg === 'button' || platformArg === 'number' || platformArg === 'select' ? 'switch' : 'sensor'; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'zha'; + } +} diff --git a/ts/integrations/zha/zha.types.ts b/ts/integrations/zha/zha.types.ts index 9cc579c..c5c382e 100644 --- a/ts/integrations/zha/zha.types.ts +++ b/ts/integrations/zha/zha.types.ts @@ -1,4 +1,346 @@ -export interface IHomeAssistantZhaConfig { - // TODO: replace with the TypeScript-native config for zha. +import type { TEntityPlatform } from '../../core/types.js'; + +export type TZhaRadioType = 'ezsp' | 'znp' | 'deconz' | 'xbee' | 'zigate' | 'efr32' | 'ti_cc' | string; +export type TZhaFlowControl = 'hardware' | 'software' | 'none' | null; +export type TZhaClusterType = 'in' | 'out' | 'client' | 'server' | string; +export type TZhaEntityPlatform = TEntityPlatform | 'lock' | 'alarm_control_panel' | 'device_tracker' | 'siren' | 'valve' | string; + +export interface IZhaConfig { + radio?: IZhaRadioConfig; + device?: IZhaRadioDeviceSettings; + radioType?: TZhaRadioType; + radio_type?: TZhaRadioType; + usbPath?: string; + usb_path?: string; + databasePath?: string; + database?: string; + enableQuirks?: boolean; + enable_quirks?: boolean; + customQuirksPath?: string; + custom_quirks_path?: string; + zigpy?: Record; + zigpy_config?: Record; + deviceConfig?: Record; + device_config?: Record; + snapshot?: IZhaSnapshot; + devices?: IZhaDevice[]; + entities?: IZhaEntityDescriptor[]; + groups?: IZhaGroup[]; + network?: IZhaNetworkInfo; + coordinator?: IZhaCoordinatorInfo; + events?: IZhaEvent[]; + commandExecutor?: TZhaCommandExecutor; [key: string]: unknown; } + +export interface IHomeAssistantZhaConfig extends IZhaConfig {} + +export interface IZhaRadioConfig { + path?: string; + devicePath?: string; + radioType?: TZhaRadioType; + baudrate?: number; + flowControl?: TZhaFlowControl; + host?: string; + port?: number; + socketUrl?: string; + serialNumber?: string; + manufacturer?: string; + model?: string; + txPower?: number; + [key: string]: unknown; +} + +export interface IZhaRadioDeviceSettings { + path?: string; + baudrate?: number; + flow_control?: TZhaFlowControl; + flowControl?: TZhaFlowControl; + [key: string]: unknown; +} + +export interface IZhaUsbRecord { + vid?: string; + pid?: string; + manufacturer?: string; + description?: string; + path?: string; + device?: string; + serialNumber?: string; + serial_number?: string; + product?: string; + [key: string]: unknown; +} + +export interface IZhaMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface IZhaManualEntry { + path?: string; + radioPath?: string; + devicePath?: string; + usbPath?: string; + socketUrl?: string; + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + radioType?: TZhaRadioType; + baudrate?: number; + flowControl?: TZhaFlowControl; + snapshot?: IZhaSnapshot; + metadata?: Record; +} + +export interface IZhaSnapshot { + connected: boolean; + radio?: IZhaRadioConfig; + network?: IZhaNetworkInfo; + coordinator?: IZhaCoordinatorInfo; + devices: IZhaDevice[]; + entities: IZhaEntityDescriptor[]; + groups: IZhaGroup[]; + events: IZhaEvent[]; +} + +export interface IZhaNetworkInfo { + panId?: number | string; + pan_id?: number | string; + extendedPanId?: string; + extended_pan_id?: string; + channel?: number; + channels?: number[]; + networkKey?: string; + network_key?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface IZhaCoordinatorInfo { + ieee?: string; + manufacturer?: string; + model?: string; + version?: string; + radioType?: TZhaRadioType; + path?: string; + [key: string]: unknown; +} + +export interface IZhaDevice { + ieee?: string; + nwk?: number | string; + name?: string; + manufacturer?: string; + model?: string; + available?: boolean; + isActiveCoordinator?: boolean; + active_coordinator?: boolean; + lqi?: number; + rssi?: number; + lastSeen?: string | number; + last_seen?: string | number; + powerSource?: string; + power_source?: string; + deviceType?: string; + device_type?: string; + quirkApplied?: boolean; + quirk_applied?: boolean; + quirkClass?: string; + quirk_class?: string; + manufacturerCode?: number; + manufacturer_code?: number; + endpoints?: IZhaEndpoint[] | Record; + entities?: IZhaEntityDescriptor[]; + signature?: Record; + exposesFeatures?: unknown; + exposes_features?: unknown; + metadata?: Record; + [key: string]: unknown; +} + +export interface IZhaEndpoint { + id?: number; + endpointId?: number; + endpoint_id?: number; + profileId?: number; + profile_id?: number; + deviceType?: string | number; + device_type?: string | number; + name?: string; + inClusters?: IZhaCluster[] | Record; + in_clusters?: IZhaCluster[] | Record; + inputClusters?: IZhaCluster[] | Record; + outClusters?: IZhaCluster[] | Record; + out_clusters?: IZhaCluster[] | Record; + outputClusters?: IZhaCluster[] | Record; + clusters?: IZhaCluster[] | Record; + [key: string]: unknown; +} + +export interface IZhaCluster { + id?: number; + clusterId?: number; + cluster_id?: number; + endpointId?: number; + endpoint_id?: number; + type?: TZhaClusterType; + clusterType?: TZhaClusterType; + cluster_type?: TZhaClusterType; + name?: string; + attributes?: IZhaAttribute[] | Record; + commands?: IZhaCommand[] | Record; + serverCommands?: IZhaCommand[] | Record; + server_commands?: IZhaCommand[] | Record; + clientCommands?: IZhaCommand[] | Record; + client_commands?: IZhaCommand[] | Record; + [key: string]: unknown; +} + +export interface IZhaAttribute { + id?: number | string; + attributeId?: number | string; + attribute_id?: number | string; + name?: string; + value?: unknown; + type?: string; + unit?: string; + manufacturer?: number | string; + [key: string]: unknown; +} + +export interface IZhaCommand { + id?: number | string; + commandId?: number | string; + command_id?: number | string; + name?: string; + type?: string; + schema?: unknown; + args?: unknown[]; + params?: Record; + [key: string]: unknown; +} + +export interface IZhaEntityDescriptor { + platform?: TZhaEntityPlatform; + entityId?: string; + entity_id?: string; + uniqueId?: string; + unique_id?: string; + deviceId?: string; + device_id?: string; + deviceIeee?: string; + ieee?: string; + endpointId?: number; + endpoint_id?: number; + clusterId?: number; + cluster_id?: number; + clusterType?: TZhaClusterType; + cluster_type?: TZhaClusterType; + name?: string; + originalName?: string; + original_name?: string; + fallbackName?: string; + fallback_name?: string; + state?: unknown; + nativeValue?: unknown; + native_value?: unknown; + value?: unknown; + isOn?: boolean; + is_on?: boolean; + isLocked?: boolean; + is_locked?: boolean; + isClosed?: boolean; + is_closed?: boolean; + available?: boolean; + deviceClass?: string; + device_class?: string; + unit?: string; + unitOfMeasurement?: string; + unit_of_measurement?: string; + currentCoverPosition?: number; + current_cover_position?: number; + currentTemperature?: number; + current_temperature?: number; + targetTemperature?: number; + target_temperature?: number; + hvacMode?: string; + hvac_mode?: string; + attributes?: Record; + infoObject?: Record; + info_object?: Record; + raw?: Record; + [key: string]: unknown; +} + +export interface IZhaGroup { + groupId?: number; + group_id?: number; + name?: string; + members?: IZhaGroupMember[]; + entities?: IZhaEntityDescriptor[]; + [key: string]: unknown; +} + +export interface IZhaGroupMember { + ieee?: string; + endpointId?: number; + endpoint_id?: number; + device?: IZhaDevice; + [key: string]: unknown; +} + +export interface IZhaEvent { + type: string; + timestamp?: number; + deviceIeee?: string; + deviceId?: string; + entityId?: string; + uniqueId?: string; + command?: IZhaClientCommand; + data?: unknown; + [key: string]: unknown; +} + +export interface IZhaClientCommand { + type: string; + service: string; + platform?: TZhaEntityPlatform; + ieee?: string; + endpointId?: number; + clusterId?: number; + clusterType?: TZhaClusterType; + command?: number | string; + commandType?: string; + attribute?: number | string; + value?: unknown; + args?: unknown[]; + params?: Record; + groupId?: number; + entityId?: string; + uniqueId?: string; + payload: Record; + target?: { + entityId?: string; + deviceId?: string; + }; +} + +export interface IZhaCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export type TZhaCommandExecutor = ( + commandArg: IZhaClientCommand +) => Promise | IZhaCommandResult | unknown;