From a144ef687c867bde768d27261fc3ae5c3a366c69 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 19:06:21 +0000 Subject: [PATCH] Add native local infrastructure integrations --- .../test.bosch_shc.discovery.node.ts | 54 + test/bosch_shc/test.bosch_shc.mapper.node.ts | 68 + test/bosch_shc/test.bosch_shc.runtime.node.ts | 93 ++ .../test.devolo_home_network.client.node.ts | 46 + ...est.devolo_home_network.configflow.node.ts | 36 + ...test.devolo_home_network.discovery.node.ts | 66 + .../test.devolo_home_network.mapper.node.ts | 107 ++ test/fritz/test.fritz.client.node.ts | 50 + test/fritz/test.fritz.discovery.node.ts | 94 ++ test/fritz/test.fritz.mapper.node.ts | 138 +++ test/glances/test.glances.client.node.ts | 63 + test/glances/test.glances.discovery.node.ts | 48 + test/glances/test.glances.mapper.node.ts | 55 + test/heos/test.heos.configflow.node.ts | 31 + test/heos/test.heos.discovery.node.ts | 56 + test/heos/test.heos.mapper.node.ts | 104 ++ test/heos/test.heos.runtime.node.ts | 74 ++ test/ipp/test.ipp.client.node.ts | 135 ++ test/ipp/test.ipp.discovery.node.ts | 58 + test/ipp/test.ipp.mapper.node.ts | 74 ++ ts/index.ts | 12 + .../.generated-by-smarthome-exchange | 1 - .../bosch_shc/bosch_shc.classes.client.ts | 116 ++ .../bosch_shc/bosch_shc.classes.configflow.ts | 84 ++ .../bosch_shc.classes.integration.ts | 118 +- .../bosch_shc/bosch_shc.discovery.ts | 131 ++ ts/integrations/bosch_shc/bosch_shc.mapper.ts | 793 ++++++++++++ ts/integrations/bosch_shc/bosch_shc.types.ts | 271 +++- ts/integrations/bosch_shc/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../devolo_home_network.classes.client.ts | 115 ++ .../devolo_home_network.classes.configflow.ts | 115 ++ ...devolo_home_network.classes.integration.ts | 97 +- .../devolo_home_network.discovery.ts | 189 +++ .../devolo_home_network.mapper.ts | 1079 ++++++++++++++++ .../devolo_home_network.types.ts | 326 ++++- ts/integrations/devolo_home_network/index.ts | 4 + .../fritz/.generated-by-smarthome-exchange | 1 - ts/integrations/fritz/fritz.classes.client.ts | 112 ++ .../fritz/fritz.classes.configflow.ts | 120 ++ .../fritz/fritz.classes.integration.ts | 124 +- ts/integrations/fritz/fritz.discovery.ts | 297 +++++ ts/integrations/fritz/fritz.mapper.ts | 1104 +++++++++++++++++ ts/integrations/fritz/fritz.types.ts | 369 +++++- ts/integrations/fritz/index.ts | 4 + ts/integrations/generated/index.ts | 20 +- .../glances/.generated-by-smarthome-exchange | 1 - .../glances/glances.classes.client.ts | 176 +++ .../glances/glances.classes.configflow.ts | 133 ++ .../glances/glances.classes.integration.ts | 115 +- ts/integrations/glances/glances.discovery.ts | 193 +++ ts/integrations/glances/glances.mapper.ts | 598 +++++++++ ts/integrations/glances/glances.types.ts | 251 +++- ts/integrations/glances/index.ts | 4 + .../heos/.generated-by-smarthome-exchange | 1 - ts/integrations/heos/heos.classes.client.ts | 735 +++++++++++ .../heos/heos.classes.configflow.ts | 58 + .../heos/heos.classes.integration.ts | 276 ++++- ts/integrations/heos/heos.discovery.ts | 221 ++++ ts/integrations/heos/heos.mapper.ts | 322 +++++ ts/integrations/heos/heos.types.ts | 232 +++- ts/integrations/heos/index.ts | 4 + .../ipp/.generated-by-smarthome-exchange | 1 - ts/integrations/ipp/index.ts | 4 + ts/integrations/ipp/ipp.classes.client.ts | 653 ++++++++++ ts/integrations/ipp/ipp.classes.configflow.ts | 116 ++ .../ipp/ipp.classes.integration.ts | 103 +- ts/integrations/ipp/ipp.discovery.ts | 219 ++++ ts/integrations/ipp/ipp.mapper.ts | 174 +++ ts/integrations/ipp/ipp.types.ts | 143 ++- 70 files changed, 11607 insertions(+), 183 deletions(-) create mode 100644 test/bosch_shc/test.bosch_shc.discovery.node.ts create mode 100644 test/bosch_shc/test.bosch_shc.mapper.node.ts create mode 100644 test/bosch_shc/test.bosch_shc.runtime.node.ts create mode 100644 test/devolo_home_network/test.devolo_home_network.client.node.ts create mode 100644 test/devolo_home_network/test.devolo_home_network.configflow.node.ts create mode 100644 test/devolo_home_network/test.devolo_home_network.discovery.node.ts create mode 100644 test/devolo_home_network/test.devolo_home_network.mapper.node.ts create mode 100644 test/fritz/test.fritz.client.node.ts create mode 100644 test/fritz/test.fritz.discovery.node.ts create mode 100644 test/fritz/test.fritz.mapper.node.ts create mode 100644 test/glances/test.glances.client.node.ts create mode 100644 test/glances/test.glances.discovery.node.ts create mode 100644 test/glances/test.glances.mapper.node.ts create mode 100644 test/heos/test.heos.configflow.node.ts create mode 100644 test/heos/test.heos.discovery.node.ts create mode 100644 test/heos/test.heos.mapper.node.ts create mode 100644 test/heos/test.heos.runtime.node.ts create mode 100644 test/ipp/test.ipp.client.node.ts create mode 100644 test/ipp/test.ipp.discovery.node.ts create mode 100644 test/ipp/test.ipp.mapper.node.ts delete mode 100644 ts/integrations/bosch_shc/.generated-by-smarthome-exchange create mode 100644 ts/integrations/bosch_shc/bosch_shc.classes.client.ts create mode 100644 ts/integrations/bosch_shc/bosch_shc.classes.configflow.ts create mode 100644 ts/integrations/bosch_shc/bosch_shc.discovery.ts create mode 100644 ts/integrations/bosch_shc/bosch_shc.mapper.ts delete mode 100644 ts/integrations/devolo_home_network/.generated-by-smarthome-exchange create mode 100644 ts/integrations/devolo_home_network/devolo_home_network.classes.client.ts create mode 100644 ts/integrations/devolo_home_network/devolo_home_network.classes.configflow.ts create mode 100644 ts/integrations/devolo_home_network/devolo_home_network.discovery.ts create mode 100644 ts/integrations/devolo_home_network/devolo_home_network.mapper.ts delete mode 100644 ts/integrations/fritz/.generated-by-smarthome-exchange create mode 100644 ts/integrations/fritz/fritz.classes.client.ts create mode 100644 ts/integrations/fritz/fritz.classes.configflow.ts create mode 100644 ts/integrations/fritz/fritz.discovery.ts create mode 100644 ts/integrations/fritz/fritz.mapper.ts delete mode 100644 ts/integrations/glances/.generated-by-smarthome-exchange create mode 100644 ts/integrations/glances/glances.classes.client.ts create mode 100644 ts/integrations/glances/glances.classes.configflow.ts create mode 100644 ts/integrations/glances/glances.discovery.ts create mode 100644 ts/integrations/glances/glances.mapper.ts delete mode 100644 ts/integrations/heos/.generated-by-smarthome-exchange create mode 100644 ts/integrations/heos/heos.classes.client.ts create mode 100644 ts/integrations/heos/heos.classes.configflow.ts create mode 100644 ts/integrations/heos/heos.discovery.ts create mode 100644 ts/integrations/heos/heos.mapper.ts delete mode 100644 ts/integrations/ipp/.generated-by-smarthome-exchange create mode 100644 ts/integrations/ipp/ipp.classes.client.ts create mode 100644 ts/integrations/ipp/ipp.classes.configflow.ts create mode 100644 ts/integrations/ipp/ipp.discovery.ts create mode 100644 ts/integrations/ipp/ipp.mapper.ts diff --git a/test/bosch_shc/test.bosch_shc.discovery.node.ts b/test/bosch_shc/test.bosch_shc.discovery.node.ts new file mode 100644 index 0000000..08f663f --- /dev/null +++ b/test/bosch_shc/test.bosch_shc.discovery.node.ts @@ -0,0 +1,54 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BoschShcConfigFlow, createBoschShcDiscoveryDescriptor } from '../../ts/integrations/bosch_shc/index.js'; +import type { IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js'; + +tap.test('matches Bosch SHC zeroconf records and validates manual candidates', async () => { + const descriptor = createBoschShcDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const mdnsResult = await mdnsMatcher.matches({ + name: 'Bosch SHC [AA:BB:CC:DD:EE:FF]._http._tcp.local.', + type: '_http._tcp.local.', + host: 'bosch-shc.local', + port: 80, + }, {}); + + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.normalizedDeviceId).toEqual('aa-bb-cc-dd-ee-ff'); + expect(mdnsResult.candidate?.integrationDomain).toEqual('bosch_shc'); + expect(mdnsResult.candidate?.manufacturer).toEqual('Bosch'); + + const manualMatcher = descriptor.getMatchers()[1]; + const manualResult = await manualMatcher.matches({ host: '192.168.1.10' }, {}); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.host).toEqual('192.168.1.10'); + + const validator = descriptor.getValidators()[0]; + const validResult = await validator.validate({ + source: 'manual', + integrationDomain: 'bosch_shc', + host: '192.168.1.10', + id: 'AA:BB:CC:DD:EE:FF', + }, {}); + expect(validResult.matched).toBeTrue(); +}); + +tap.test('config flow preserves credentials and refuses native pairing without executor', async () => { + const flow = new BoschShcConfigFlow(); + const step = await flow.start({ source: 'manual', integrationDomain: 'bosch_shc', host: '192.168.1.10' }, {}); + + const pairing = await step.submit?.({ host: '192.168.1.10', password: 'secret' }); + expect(pairing?.kind).toEqual('error'); + expect(pairing?.error).toContain('executor'); + + const done = await step.submit?.({ host: '192.168.1.10', sslCertificate: '/config/bosch/cert.pem', sslKey: '/config/bosch/key.pem', token: 'token:bosch-shc' }); + expect(done?.kind).toEqual('done'); + expect(done?.config?.host).toEqual('192.168.1.10'); + expect(done?.config?.sslCertificate).toEqual('/config/bosch/cert.pem'); + + const snapshot: IBoschShcSnapshot = { host: '192.168.1.10', devices: [], information: { macAddress: 'AA:BB:CC:DD:EE:FF' } }; + const snapshotDone = await step.submit?.({ host: '192.168.1.10', snapshotJson: JSON.stringify(snapshot) }); + expect(snapshotDone?.kind).toEqual('done'); + expect(snapshotDone?.config?.snapshot?.information?.macAddress).toEqual('AA:BB:CC:DD:EE:FF'); +}); + +export default tap.start(); diff --git a/test/bosch_shc/test.bosch_shc.mapper.node.ts b/test/bosch_shc/test.bosch_shc.mapper.node.ts new file mode 100644 index 0000000..ae13b2a --- /dev/null +++ b/test/bosch_shc/test.bosch_shc.mapper.node.ts @@ -0,0 +1,68 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BoschShcMapper } from '../../ts/integrations/bosch_shc/index.js'; +import type { IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js'; + +const snapshot: IBoschShcSnapshot = { + host: '192.168.1.10', + name: 'Home Controller', + information: { + shcIpAddress: '192.168.1.10', + macAddress: 'AA:BB:CC:DD:EE:FF', + softwareUpdateState: { + swInstalledVersion: '10.20.3000', + swUpdateState: 'NO_UPDATE_AVAILABLE', + }, + }, + devices: [ + { id: 'hdm:HomeMaticIP:trv', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'TRV', manufacturer: 'BOSCH', serial: 'TRV123', name: 'Radiator', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['TemperatureLevel', 'ValveTappet', 'BatteryLevel'] }, + { id: 'hdm:HomeMaticIP:window', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'SWD', manufacturer: 'BOSCH', serial: 'SWD123', name: 'Patio Window', status: 'AVAILABLE', profile: 'REGULAR_WINDOW', deviceServiceIds: ['ShutterContact', 'BatteryLevel'] }, + { id: 'hdm:ZigBee:plug', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'PSM', manufacturer: 'BOSCH', serial: 'PSM123', name: 'Kitchen Plug', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PowerSwitch', 'PowerMeter', 'Routing'] }, + { id: 'camera:360', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'CAMERA_360', manufacturer: 'BOSCH', serial: 'CAM360', name: 'Porch Camera', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PrivacyMode'] }, + { id: 'light:hue', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'HUE_LIGHT', manufacturer: 'BOSCH', serial: 'HUE123', name: 'Desk Lamp', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['BinarySwitch', 'MultiLevelSwitch', 'HueColorTemperature'] }, + { id: 'shutter:living', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'BBL', manufacturer: 'BOSCH', serial: 'BBL123', name: 'Living Shutter', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['ShutterControl'] }, + { id: 'roomClimateControl_hz_1', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'ROOM_CLIMATE_CONTROL', manufacturer: 'BOSCH', serial: 'RCC123', name: 'Living Climate', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['TemperatureLevel', 'RoomClimateControl'] }, + ], + services: [ + { id: 'TemperatureLevel', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'temperatureLevelState', temperature: 21.3 } }, + { id: 'ValveTappet', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'valveTappetState', position: 44, value: 'VALVE_ADAPTION_SUCCESSFUL' } }, + { id: 'BatteryLevel', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'batteryLevelState' }, faults: { entries: [{ type: 'OK' }] } }, + { id: 'ShutterContact', deviceId: 'hdm:HomeMaticIP:window', state: { '@type': 'shutterContactState', value: 'OPEN' } }, + { id: 'BatteryLevel', deviceId: 'hdm:HomeMaticIP:window', state: { '@type': 'batteryLevelState' }, faults: { entries: [{ type: 'LOW_BATTERY' }] } }, + { id: 'PowerSwitch', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerSwitchState', switchState: 'ON', automaticPowerOffTime: 0 } }, + { id: 'PowerMeter', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerMeterState', powerConsumption: 123, energyConsumption: 4567 } }, + { id: 'Routing', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'routingState', value: 'DISABLED' } }, + { id: 'PrivacyMode', deviceId: 'camera:360', state: { '@type': 'privacyModeState', value: 'DISABLED' } }, + { id: 'BinarySwitch', deviceId: 'light:hue', state: { '@type': 'binarySwitchState', on: true } }, + { id: 'MultiLevelSwitch', deviceId: 'light:hue', state: { '@type': 'multiLevelSwitchState', level: 42 } }, + { id: 'HueColorTemperature', deviceId: 'light:hue', state: { '@type': 'hueColorTemperatureState', colorTemperature: 300, colorTemperatureRange: { minCt: 153, maxCt: 500 } } }, + { id: 'ShutterControl', deviceId: 'shutter:living', state: { '@type': 'shutterControlState', operationState: 'CLOSING', level: 0.25, calibrated: true } }, + { id: 'TemperatureLevel', deviceId: 'roomClimateControl_hz_1', state: { '@type': 'temperatureLevelState', temperature: 20.5 } }, + { id: 'RoomClimateControl', deviceId: 'roomClimateControl_hz_1', state: { '@type': 'roomClimateControlState', operationMode: 'MANUAL', setpointTemperature: 22, boostMode: false, low: false, summerMode: false, supportsBoostMode: true } }, + ], +}; + +tap.test('maps Bosch SHC snapshot devices and entities', async () => { + const devices = BoschShcMapper.toDevices(snapshot); + const entities = BoschShcMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'bosch_shc.controller.aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'cover'))).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'light'))).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'climate'))).toBeTrue(); + + expect(entities.find((entityArg) => entityArg.id === 'sensor.radiator_temperature')?.state).toEqual(21.3); + expect(entities.find((entityArg) => entityArg.id === 'sensor.radiator_valvetappet')?.attributes?.valve_tappet_state).toEqual('VALVE_ADAPTION_SUCCESSFUL'); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.patio_window')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.patio_window_battery')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_plug')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_plug_routing')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_plug_power')?.state).toEqual(123); + expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_plug_energy')?.state).toEqual(4.567); + expect(entities.find((entityArg) => entityArg.id === 'switch.porch_camera_privacy_mode')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'light.desk_lamp')?.attributes?.brightness).toEqual(42); + expect(entities.find((entityArg) => entityArg.id === 'cover.living_shutter')?.attributes?.currentPosition).toEqual(25); + expect(entities.find((entityArg) => entityArg.id === 'cover.living_shutter')?.state).toEqual('closing'); + expect(entities.find((entityArg) => entityArg.id === 'climate.living_climate')?.attributes?.targetTemperature).toEqual(22); +}); + +export default tap.start(); diff --git a/test/bosch_shc/test.bosch_shc.runtime.node.ts b/test/bosch_shc/test.bosch_shc.runtime.node.ts new file mode 100644 index 0000000..6629a31 --- /dev/null +++ b/test/bosch_shc/test.bosch_shc.runtime.node.ts @@ -0,0 +1,93 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BoschShcIntegration } from '../../ts/integrations/bosch_shc/index.js'; +import type { IBoschShcModeledCommand, IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js'; + +const snapshot: IBoschShcSnapshot = { + host: '192.168.1.10', + devices: [ + { id: 'hdm:ZigBee:plug', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'PSM', manufacturer: 'BOSCH', serial: 'PSM123', name: 'Kitchen Plug', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PowerSwitch'] }, + { id: 'shutter:living', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'BBL', manufacturer: 'BOSCH', serial: 'BBL123', name: 'Living Shutter', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['ShutterControl'] }, + ], + services: [ + { id: 'PowerSwitch', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerSwitchState', switchState: 'ON', automaticPowerOffTime: 0 } }, + { id: 'ShutterControl', deviceId: 'shutter:living', state: { '@type': 'shutterControlState', operationState: 'STOPPED', level: 0.5, calibrated: true } }, + ], +}; + +tap.test('runs modeled Bosch SHC service commands through an injected executor', async () => { + const calls: IBoschShcModeledCommand[] = []; + const runtime = await new BoschShcIntegration().setup({ + host: '192.168.1.10', + snapshot, + executor: async (commandArg) => { + calls.push(commandArg); + return { ok: true }; + }, + }, {}); + + const result = await runtime.callService?.({ + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.kitchen_plug' }, + }); + expect(result?.success).toBeTrue(); + expect(calls[0].action).toEqual('put_device_service_state'); + expect(calls[0].path).toEqual('/devices/hdm:ZigBee:plug/services/PowerSwitch/state'); + expect(calls[0].body).toEqual({ '@type': 'powerSwitchState', switchState: 'OFF' }); + + const coverResult = await runtime.callService?.({ + domain: 'cover', + service: 'set_cover_position', + target: { entityId: 'cover.living_shutter' }, + data: { position: 75 }, + }); + expect(coverResult?.success).toBeTrue(); + expect(calls[1].body).toEqual({ '@type': 'shutterControlState', level: 0.75 }); + await runtime.destroy(); +}); + +tap.test('returns clear unsupported errors without executor for writes and pairing', async () => { + const runtime = await new BoschShcIntegration().setup({ host: '192.168.1.10', snapshot }, {}); + const result = await runtime.callService?.({ + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.kitchen_plug' }, + }); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain('requires config.snapshot or an injected executor'); + + const pairResult = await runtime.callService?.({ + domain: 'bosch_shc', + service: 'pair_client', + target: {}, + data: { system_password: 'secret' }, + }); + expect(pairResult?.success).toBeFalse(); + expect(pairResult?.error).toContain('pairing transport'); + await runtime.destroy(); +}); + +tap.test('models Bosch SHC pairing only through an injected executor', async () => { + const calls: IBoschShcModeledCommand[] = []; + const runtime = await new BoschShcIntegration().setup({ + host: '192.168.1.10', + executor: async (commandArg) => { + calls.push(commandArg); + return { token: 'token:bosch-shc' }; + }, + }, {}); + const result = await runtime.callService?.({ + domain: 'bosch_shc', + service: 'pair_client', + target: {}, + data: { system_password: 'secret', client_id: 'homeassistant', client_name: 'HomeAssistant' }, + }); + + expect(result?.success).toBeTrue(); + expect(calls[0].action).toEqual('pair_client'); + expect(calls[0].sensitiveFields).toEqual(['systemPassword', 'certificatePem']); + expect(calls[0].body?.clientName).toEqual('HomeAssistant'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/devolo_home_network/test.devolo_home_network.client.node.ts b/test/devolo_home_network/test.devolo_home_network.client.node.ts new file mode 100644 index 0000000..89cd2f0 --- /dev/null +++ b/test/devolo_home_network/test.devolo_home_network.client.node.ts @@ -0,0 +1,46 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantDevoloHomeNetworkIntegration, type IDevoloCommand, type IDevoloConfig } from '../../ts/integrations/devolo_home_network/index.js'; + +const config: IDevoloConfig = { + host: '192.168.1.20', + name: 'Office Adapter', + serialNumber: 'S12345', + features: ['led'], + switches: { leds: true }, +}; + +tap.test('does not fake devolo command success without an injected executor', async () => { + const runtime = await new HomeAssistantDevoloHomeNetworkIntegration().setup(config, {}); + const result = await runtime.callService!({ + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.office_adapter_enable_leds' }, + }); + + expect(result.success).toBeFalse(); + expect(result.error || '').toInclude('not faked'); + await runtime.destroy(); +}); + +tap.test('executes devolo commands through an injected executor', async () => { + let command: IDevoloCommand | undefined; + const runtime = await new HomeAssistantDevoloHomeNetworkIntegration().setup({ + ...config, + commandExecutor: async (commandArg) => { + command = commandArg; + return { success: true, data: { accepted: true } }; + }, + }, {}); + const result = await runtime.callService!({ + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.office_adapter_enable_leds' }, + }); + + expect(result.success).toBeTrue(); + expect(command?.type).toEqual('device.set_leds'); + expect(command?.enabled).toEqual(false); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/devolo_home_network/test.devolo_home_network.configflow.node.ts b/test/devolo_home_network/test.devolo_home_network.configflow.node.ts new file mode 100644 index 0000000..9de00ec --- /dev/null +++ b/test/devolo_home_network/test.devolo_home_network.configflow.node.ts @@ -0,0 +1,36 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DevoloHomeNetworkConfigFlow } from '../../ts/integrations/devolo_home_network/index.js'; + +tap.test('creates devolo config from local discovery candidate', async () => { + const flow = new DevoloHomeNetworkConfigFlow(); + const step = await flow.start({ + source: 'mdns', + integrationDomain: 'devolo_home_network', + id: 'S12345', + host: '192.168.1.20', + name: 'Office Adapter', + model: 'Magic 2 WiFi 6', + metadata: { MT: '8528', SN: 'S12345' }, + }, {}); + const done = await step.submit!({ password: 'local-secret' }); + + expect(step.kind).toEqual('form'); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.20'); + expect(done.config?.serialNumber).toEqual('S12345'); + expect(done.config?.metadata?.liveHttpImplemented).toEqual(false); +}); + +tap.test('rejects unsupported Home Control candidates in config flow', async () => { + const flow = new DevoloHomeNetworkConfigFlow(); + const step = await flow.start({ + source: 'mdns', + integrationDomain: 'devolo_home_network', + host: '192.168.1.21', + metadata: { homeControl: true }, + }, {}); + + expect(step.kind).toEqual('error'); +}); + +export default tap.start(); diff --git a/test/devolo_home_network/test.devolo_home_network.discovery.node.ts b/test/devolo_home_network/test.devolo_home_network.discovery.node.ts new file mode 100644 index 0000000..accdbd5 --- /dev/null +++ b/test/devolo_home_network/test.devolo_home_network.discovery.node.ts @@ -0,0 +1,66 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createDevoloHomeNetworkDiscoveryDescriptor } from '../../ts/integrations/devolo_home_network/index.js'; + +tap.test('matches devolo device API mDNS records', async () => { + const descriptor = createDevoloHomeNetworkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_dvl-deviceapi._tcp.local.', + name: 'devolo-office._dvl-deviceapi._tcp.local.', + host: '192.168.1.20', + port: 80, + txt: { + Product: 'Magic 2 WiFi 6', + SN: 'S12345', + MT: '8528', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('devolo_home_network'); + expect(result.normalizedDeviceId).toEqual('S12345'); + expect(result.candidate?.metadata?.MT).toEqual('8528'); +}); + +tap.test('rejects devolo Home Control central units', async () => { + const descriptor = createDevoloHomeNetworkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_dvl-deviceapi._tcp.local.', + host: '192.168.1.21', + txt: { Product: 'Home Control Central Unit', SN: 'HC1', MT: '2600' }, + }, {}); + + expect(result.matched).toBeFalse(); + expect(result.metadata?.homeControl).toBeTrue(); +}); + +tap.test('matches and validates manual snapshot candidates', async () => { + const descriptor = createDevoloHomeNetworkDiscoveryDescriptor(); + const manualMatcher = descriptor.getMatchers()[1]; + const validator = descriptor.getValidators()[0]; + const manual = await manualMatcher.matches({ + host: '192.168.1.22', + product: 'devolo Magic 2 LAN', + serialNumber: 'S67890', + snapshot: { + connected: true, + device: { host: '192.168.1.22', name: 'Basement Adapter', serialNumber: 'S67890' }, + plcDevices: [], + plcLinks: [], + wifiStations: [], + neighboringWifiNetworks: [], + sensors: {}, + switches: {}, + actions: [], + events: [], + }, + }, {}); + const validated = await validator.validate(manual.candidate!, {}); + + expect(manual.matched).toBeTrue(); + expect(validated.matched).toBeTrue(); + expect(validated.metadata?.liveHttpImplemented).toEqual(false); +}); + +export default tap.start(); diff --git a/test/devolo_home_network/test.devolo_home_network.mapper.node.ts b/test/devolo_home_network/test.devolo_home_network.mapper.node.ts new file mode 100644 index 0000000..f3859af --- /dev/null +++ b/test/devolo_home_network/test.devolo_home_network.mapper.node.ts @@ -0,0 +1,107 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DevoloHomeNetworkMapper, type IDevoloConfig } from '../../ts/integrations/devolo_home_network/index.js'; + +const config: IDevoloConfig = { + host: '192.168.1.20', + name: 'Office Adapter', + product: 'Magic 2 WiFi 6', + serialNumber: 'S12345', + macAddress: 'AA-AA-AA-AA-AA-AA', + firmwareVersion: '5.10.0', + features: ['led', 'restart', 'update', 'wifi1'], + plcDevices: [ + { + macAddress: 'AA:AA:AA:AA:AA:AA', + userDeviceName: 'Office Adapter', + topology: 'LOCAL', + attachedToRouter: true, + }, + { + macAddress: 'BB:BB:BB:BB:BB:BB', + userDeviceName: 'Garage Adapter', + topology: 'REMOTE', + attachedToRouter: true, + }, + ], + plcLinks: [ + { + macAddressFrom: 'AA:AA:AA:AA:AA:AA', + macAddressTo: 'BB:BB:BB:BB:BB:BB', + rxRate: 410, + txRate: 395, + }, + ], + wifiStations: [ + { + macAddress: 'CC:CC:CC:CC:CC:CC', + hostname: 'Kitchen Tablet', + ipAddress: '192.168.1.44', + band: 1, + vapType: 0, + rssi: -55, + }, + ], + neighboringWifiNetworks: [ + { ssid: 'Neighbor', bssid: 'DD:DD:DD:DD:DD:DD', band: 1, channel: 44, rssi: -70 }, + ], + switches: { + leds: true, + guestWifi: { enabled: false, ssid: 'Guest', key: 'secret' }, + }, + firmware: { + installedVersion: '5.10.0', + latestVersion: '5.11.0', + }, +}; + +tap.test('maps devolo PLC/Wi-Fi snapshot to devices and HA-style entities', async () => { + const snapshot = DevoloHomeNetworkMapper.toSnapshot(config); + const devices = DevoloHomeNetworkMapper.toDevices(snapshot); + const entities = DevoloHomeNetworkMapper.toEntities(snapshot); + + expect(snapshot.connected).toBeTrue(); + expect(snapshot.sensors.connected_plc_devices).toEqual(1); + expect(snapshot.sensors.connected_wifi_clients).toEqual(1); + expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.device.s12345')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.plc.bb_bb_bb_bb_bb_bb')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.station.cc_cc_cc_cc_cc_cc')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_connected_wifi_clients')?.state).toEqual(1); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_plc_downlink_phy_rate_garage_adapter')?.state).toEqual(410); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_plc_uplink_phy_rate_garage_adapter')?.state).toEqual(395); + expect(entities.find((entityArg) => entityArg.id === 'switch.office_adapter_enable_leds')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.office_adapter_enable_guest_wi_fi')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'update.office_adapter_regular_firmware')?.attributes?.latestVersion).toEqual('5.11.0'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_tablet_wi_fi_band')?.state).toEqual('5'); +}); + +tap.test('maps only represented devolo service actions to safe commands', async () => { + const snapshot = DevoloHomeNetworkMapper.toSnapshot(config); + const ledCommand = DevoloHomeNetworkMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.office_adapter_enable_leds' }, + }); + const restartCommand = DevoloHomeNetworkMapper.commandForService(snapshot, { + domain: 'button', + service: 'press', + target: { entityId: 'button.office_adapter_restart_device' }, + }); + const updateCommand = DevoloHomeNetworkMapper.commandForService(snapshot, { + domain: 'update', + service: 'install', + target: { entityId: 'update.office_adapter_regular_firmware' }, + }); + const unsupported = DevoloHomeNetworkMapper.commandForService(DevoloHomeNetworkMapper.toSnapshot({ ...config, features: ['wifi1'], actions: [] }), { + domain: 'button', + service: 'press', + target: { entityId: 'button.office_adapter_restart_device' }, + }); + + expect(ledCommand?.type).toEqual('device.set_leds'); + expect(ledCommand?.enabled).toEqual(false); + expect(restartCommand?.type).toEqual('device.restart'); + expect(updateCommand?.type).toEqual('firmware.install'); + expect(unsupported).toBeUndefined(); +}); + +export default tap.start(); diff --git a/test/fritz/test.fritz.client.node.ts b/test/fritz/test.fritz.client.node.ts new file mode 100644 index 0000000..1995dc5 --- /dev/null +++ b/test/fritz/test.fritz.client.node.ts @@ -0,0 +1,50 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantFritzIntegration, type IFritzCommand, type IFritzConfig } from '../../ts/integrations/fritz/index.js'; + +const config: IFritzConfig = { + host: '192.168.178.1', + snapshot: { + connected: true, + router: { + name: 'Home Fritz', + host: '192.168.178.1', + serialNumber: 'AABBCCDDEEFF', + actions: ['reboot'], + }, + devices: [], + interfaces: [], + connection: {}, + sensors: {}, + wifiNetworks: [], + portForwards: [], + callDeflections: [], + }, +}; + +tap.test('does not fake FRITZ TR-064 command success without injected executor', async () => { + const runtime = await new HomeAssistantFritzIntegration().setup(config, {}); + const result = await runtime.callService!({ domain: 'fritz', service: 'reboot', target: {} }); + + expect(result.success).toBeFalse(); + expect(result.error || '').toInclude('not faked'); + await runtime.destroy(); +}); + +tap.test('executes explicit FRITZ commands through injected executor', async () => { + let command: IFritzCommand | undefined; + const runtime = await new HomeAssistantFritzIntegration().setup({ + ...config, + commandExecutor: async (commandArg) => { + command = commandArg; + return { success: true, data: { accepted: true } }; + }, + }, {}); + const result = await runtime.callService!({ domain: 'fritz', service: 'reboot', target: {} }); + + expect(result.success).toBeTrue(); + expect(command?.type).toEqual('router.action'); + expect(command?.action).toEqual('reboot'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/fritz/test.fritz.discovery.node.ts b/test/fritz/test.fritz.discovery.node.ts new file mode 100644 index 0000000..abb7172 --- /dev/null +++ b/test/fritz/test.fritz.discovery.node.ts @@ -0,0 +1,94 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FritzConfigFlow, createFritzDiscoveryDescriptor } from '../../ts/integrations/fritz/index.js'; + +tap.test('matches and validates manual FRITZ entries', async () => { + const descriptor = createFritzDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + host: '192.168.178.1', + name: 'Home Fritz', + model: 'FRITZ!Box 7590', + serialNumber: 'AABBCCDDEEFF', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('fritz'); + expect(result.candidate?.port).toEqual(49000); + expect(result.normalizedDeviceId).toEqual('AABBCCDDEEFF'); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.metadata?.liveTr064Implemented).toBeFalse(); +}); + +tap.test('accepts snapshot-only setup and rejects unrelated manual entries', async () => { + const descriptor = createFritzDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const snapshotResult = await matcher.matches({ + snapshot: { + connected: true, + router: { name: 'Snapshot Fritz', serialNumber: 'AABBCCDDEEFF', model: 'FRITZ!Box 7530' }, + devices: [], + interfaces: [], + connection: {}, + sensors: {}, + wifiNetworks: [], + portForwards: [], + callDeflections: [], + }, + }, {}); + const unrelated = await matcher.matches({ name: 'Generic Switch', model: 'GS108' }, {}); + + expect(snapshotResult.matched).toBeTrue(); + expect(snapshotResult.confidence).toEqual('certain'); + expect(unrelated.matched).toBeFalse(); +}); + +tap.test('matches Home Assistant supported FRITZ SSDP candidates', async () => { + const descriptor = createFritzDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + st: 'urn:schemas-upnp-org:device:fritzbox:1', + ssdpLocation: 'http://192.168.178.1:49000/rootDesc.xml', + upnp: { + friendlyName: 'FRITZ!Box 7590', + modelName: 'FRITZ!Box 7590', + UDN: 'uuid:abcdef01-2345-6789-abcd-ef0123456789', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('certain'); + expect(result.candidate?.source).toEqual('ssdp'); + expect(result.candidate?.host).toEqual('192.168.178.1'); + expect(result.candidate?.metadata?.upstreamSupportsZeroconf).toBeFalse(); +}); + +tap.test('matches supplied FRITZ zeroconf candidates without claiming upstream zeroconf manifest support', async () => { + const descriptor = createFritzDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[2]; + const result = await matcher.matches({ + host: 'fritz.box', + name: 'FRITZ!Box 7530', + model: 'FRITZ!Box 7530', + serviceType: '_http._tcp.local', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.source).toEqual('mdns'); + expect(result.candidate?.metadata?.upstreamSupportsZeroconf).toBeFalse(); +}); + +tap.test('builds FRITZ config flow without claiming live TR-064 validation', async () => { + const flow = new FritzConfigFlow(); + const step = await flow.start({ source: 'ssdp', host: '192.168.178.1', metadata: { ssl: true } }, {}); + const done = await step.submit!({ host: '192.168.178.1', ssl: true, username: 'homeassistant', password: 'secret', featureDeviceTracking: true }); + + expect(done.kind).toEqual('done'); + expect(done.config?.port).toEqual(49443); + expect(done.config?.ssl).toBeTrue(); + expect(done.config?.metadata?.liveTr064Implemented).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/fritz/test.fritz.mapper.node.ts b/test/fritz/test.fritz.mapper.node.ts new file mode 100644 index 0000000..0c72bf2 --- /dev/null +++ b/test/fritz/test.fritz.mapper.node.ts @@ -0,0 +1,138 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { FritzMapper, type IFritzSnapshot } from '../../ts/integrations/fritz/index.js'; + +const snapshot: IFritzSnapshot = { + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', + router: { + host: '192.168.178.1', + port: 49000, + ssl: false, + name: 'Home Fritz', + model: 'FRITZ!Box 7590', + serialNumber: 'AABBCCDDEEFF', + macAddress: 'AA:BB:CC:DD:EE:FF', + firmware: '8.02', + latestFirmware: '8.03', + updateAvailable: true, + actions: ['reboot', 'reconnect', 'firmware_update'], + }, + devices: [ + { + mac: '11:22:33:44:55:66', + name: 'Kitchen Phone', + ipAddress: '192.168.178.45', + connected: true, + connectedTo: 'Repeater', + connectionType: 'LAN', + ssid: 'Home WiFi', + wanAccess: true, + }, + ], + interfaces: [ + { + name: 'wan', + label: 'WAN', + connected: true, + rxBytes: 2_000_000_000, + txBytes: 1_000_000_000, + rxRateKbps: 125.5, + txRateKbps: 42.5, + }, + ], + connection: { + connection: 'dsl', + wanEnabled: true, + ipv6Active: true, + isConnected: true, + isLinked: true, + externalIp: '203.0.113.10', + externalIpv6: '2001:db8::1', + transmissionRate: [42_500, 125_500], + maxBitRate: [50_000_000, 250_000_000], + maxLinkedBitRate: [60_000_000, 300_000_000], + noiseMargin: [80, 90], + attenuation: [120, 130], + bytesSent: 1_000_000_000, + bytesReceived: 2_000_000_000, + cpuTemperature: 56, + }, + sensors: {}, + wifiNetworks: [ + { index: 1, switchName: 'Main 2.4Ghz', ssid: 'Home WiFi', enabled: true, band: '2.4Ghz' }, + ], + portForwards: [ + { index: 0, description: 'SSH', enabled: true, internalClient: '192.168.178.2', internalPort: 22, externalPort: 22, protocol: 'TCP', connectionType: 'WANIPConnection' }, + ], + callDeflections: [ + { id: 1, enabled: false, type: 'fromNumber', number: '123', deflectionToNumber: '456', mode: 'eImmediately' }, + ], + update: { + installedVersion: '8.02', + latestVersion: '8.03', + updateAvailable: true, + releaseUrl: 'https://example.invalid/fritzos', + }, + actions: [ + { target: 'service', action: 'set_guest_wifi_password' }, + { target: 'service', action: 'dial' }, + ], +}; + +tap.test('maps FRITZ router, tracker equivalents, interfaces, traffic sensors, and controls', async () => { + const normalized = FritzMapper.toSnapshot({ snapshot }); + const devices = FritzMapper.toDevices(normalized); + const entities = FritzMapper.toEntities(normalized); + + expect(devices.some((deviceArg) => deviceArg.id === 'fritz.router.aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'fritz.client.11_22_33_44_55_66')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_gb_received')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_download_throughput')?.state).toEqual(125.5); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_wan_download')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.attributes?.nativePlatform).toEqual('device_tracker'); + expect(entities.find((entityArg) => entityArg.id === 'switch.home_fritz_wi_fi_main_2_4ghz')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_phone_internet_access')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'button.kitchen_phone_wake_on_lan')?.available).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'update.home_fritz_fritz_os')?.attributes?.latestVersion).toEqual('8.03'); +}); + +tap.test('models only represented FRITZ commands safely', async () => { + const normalized = FritzMapper.toSnapshot({ snapshot }); + const rebootCommand = FritzMapper.commandForService(normalized, { + domain: 'fritz', + service: 'reboot', + target: {}, + }); + const wifiCommand = FritzMapper.commandForService(normalized, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.home_fritz_wi_fi_main_2_4ghz' }, + }); + const wolCommand = FritzMapper.commandForService(normalized, { + domain: 'button', + service: 'press', + target: { entityId: 'button.kitchen_phone_wake_on_lan' }, + }); + const serviceCommand = FritzMapper.commandForService(normalized, { + domain: 'fritz', + service: 'set_guest_wifi_password', + target: {}, + data: { password: 'safe-passphrase' }, + }); + const invalidGuestPassword = FritzMapper.commandForService(normalized, { + domain: 'fritz', + service: 'set_guest_wifi_password', + target: {}, + data: { password: 'short' }, + }); + + expect(rebootCommand?.type).toEqual('router.action'); + expect(wifiCommand?.action).toEqual('set_wifi_enabled'); + expect(wifiCommand?.payload?.enabled).toBeFalse(); + expect(wolCommand?.type).toEqual('client.action'); + expect(wolCommand?.mac).toEqual('11:22:33:44:55:66'); + expect(serviceCommand?.type).toEqual('service.action'); + expect(invalidGuestPassword).toBeUndefined(); +}); + +export default tap.start(); diff --git a/test/glances/test.glances.client.node.ts b/test/glances/test.glances.client.node.ts new file mode 100644 index 0000000..a790252 --- /dev/null +++ b/test/glances/test.glances.client.node.ts @@ -0,0 +1,63 @@ +import { createServer } from 'node:http'; +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GlancesClient, type IGlancesRawData } from '../../ts/integrations/glances/index.js'; + +const rawData: IGlancesRawData = { + system: { hostname: 'nas', os_name: 'Linux', platform: 'Linux' }, + quicklook: { cpu: 12.5 }, + mem: { percent: 55.5, used: 2147483648, free: 1073741824 }, + fs: [{ mnt_point: '/', used: 107374182400, size: 214748364800, percent: 50 }], + network: [{ interface_name: 'eth0', rx: 600, tx: 300, time_since_update: 3, is_up: true, speed: 1073741824 }], + load: { min1: 0.12, min5: 0.24, min15: 0.42 }, + processcount: { running: 3, total: 144, thread: 300, sleeping: 141 }, + sensors: [{ label: 'CPU Core', type: 'temperature_core', value: 48.2 }], +}; + +tap.test('probes Glances v4 then v3 over local HTTP', async () => { + const requests: string[] = []; + const server = createServer((requestArg, responseArg) => { + requests.push(requestArg.url || ''); + if (requestArg.url === '/api/4/all') { + responseArg.statusCode = 404; + responseArg.end('no v4 here'); + return; + } + if (requestArg.url === '/api/3/all') { + expect(requestArg.headers.authorization).toEqual(`Basic ${Buffer.from('glances:secret').toString('base64')}`); + responseArg.setHeader('content-type', 'application/json'); + responseArg.end(JSON.stringify(rawData)); + return; + } + responseArg.statusCode = 404; + responseArg.end('not found'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const snapshot = await new GlancesClient({ host: '127.0.0.1', port, username: 'glances', password: 'secret', timeoutMs: 1000 }).getSnapshot(); + + expect(requests).toEqual(['/api/4/all', '/api/3/all']); + expect(snapshot.online).toBeTrue(); + expect(snapshot.apiVersion).toEqual(3); + expect(snapshot.host.hostname).toEqual('nas'); + expect(snapshot.sensorData.network?.eth0?.rx).toEqual(200); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'cpu_use_percent')?.value).toEqual(12.5); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => { + const client = new GlancesClient({}); + const snapshot = await client.getSnapshot(); + const result = await client.refresh(); + + expect(snapshot.online).toBeFalse(); + expect(snapshot.sensors.length).toEqual(0); + expect(result.success).toBeFalse(); + expect(result.error).toContain('endpoint'); +}); + +export default tap.start(); diff --git a/test/glances/test.glances.discovery.node.ts b/test/glances/test.glances.discovery.node.ts new file mode 100644 index 0000000..85984ae --- /dev/null +++ b/test/glances/test.glances.discovery.node.ts @@ -0,0 +1,48 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GlancesConfigFlow, createGlancesDiscoveryDescriptor } from '../../ts/integrations/glances/index.js'; + +tap.test('matches and validates manual Glances entries', async () => { + const descriptor = createGlancesDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ host: '192.168.1.70', name: 'NAS Glances' }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('glances'); + expect(result.candidate?.port).toEqual(61208); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.normalizedDeviceId).toEqual('192.168.1.70:61208'); +}); + +tap.test('matches HTTP API candidates and creates config flow output', async () => { + const descriptor = createGlancesDiscoveryDescriptor(); + const httpMatcher = descriptor.getMatchers()[1]; + const result = await httpMatcher.matches({ url: 'http://nas.local:61208/api/4/all' }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('nas.local'); + expect(result.candidate?.metadata?.apiVersion).toEqual(4); + + const step = await new GlancesConfigFlow().start(result.candidate!, {}); + const done = await step.submit!({ username: 'admin', password: 'secret', apiVersion: '4' }); + + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('nas.local'); + expect(done.config?.port).toEqual(61208); + expect(done.config?.apiVersion).toEqual(4); + expect(done.config?.username).toEqual('admin'); +}); + +tap.test('rejects candidates without Glances hints or usable data', async () => { + const descriptor = createGlancesDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ name: 'Generic service' }, {}); + expect(result.matched).toBeFalse(); + + const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Glances' }, {}); + expect(validation.matched).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/glances/test.glances.mapper.node.ts b/test/glances/test.glances.mapper.node.ts new file mode 100644 index 0000000..3e86da8 --- /dev/null +++ b/test/glances/test.glances.mapper.node.ts @@ -0,0 +1,55 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { GlancesMapper, type IGlancesRawData } from '../../ts/integrations/glances/index.js'; + +const rawData: IGlancesRawData = { + system: { hostname: 'nas', os_name: 'Linux', platform: 'Linux', hr_name: 'Ubuntu 24.04' }, + quicklook: { cpu: 12.5 }, + percpu: [{ cpu_number: 0, total: 10 }], + mem: { percent: 55.5, used: 2147483648, free: 1073741824 }, + memswap: { percent: 10, used: 1073741824, free: 9663676416 }, + fs: [{ mnt_point: '/', used: 107374182400, size: 214748364800, free: 107374182400, percent: 50 }], + diskio: [{ disk_name: 'sda', read_bytes: 1000, write_bytes: 2000, time_since_update: 2 }], + network: [{ interface_name: 'eth0', bytes_recv_rate_per_sec: 1234, bytes_sent_rate_per_sec: 5678, is_up: true, speed: 1073741824 }], + load: { min1: 0.12, min5: 0.24, min15: 0.42 }, + processcount: { running: 3, total: 144, thread: 300, sleeping: 141 }, + sensors: [{ label: 'CPU Core', type: 'temperature_core', value: 48.2 }], +}; + +tap.test('maps raw Glances API snapshots to CPU memory disk network load temperature and process sensors', async () => { + const snapshot = GlancesMapper.toSnapshot({ config: { name: 'NAS' }, rawData, apiVersion: 4, online: true, source: 'manual' }); + const entities = GlancesMapper.toEntities(snapshot); + const devices = GlancesMapper.toDevices(snapshot); + + expect(snapshot.sensorData.mem?.memory_use).toEqual(2048); + expect(snapshot.sensorData.fs?.['/']?.disk_size).toEqual(200); + expect(snapshot.sensorData.network?.eth0?.rx).toEqual(1234); + expect(devices[0].id).toEqual('glances.host.nas'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'cpu_use_percent')?.state).toEqual(12.5); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'memory_use_percent')?.state).toEqual(55.5); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'disk_glances_use_percent')?.state).toEqual(50); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'network_eth0_rx')?.state).toEqual(1234); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'processor_load')?.state).toEqual(0.42); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'sensor_cpu_core_temperature_core')?.attributes?.deviceClass).toEqual('temperature'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'process_running')?.state).toEqual(3); +}); + +tap.test('maps manual HA sensor data without inventing absent values', async () => { + const snapshot = GlancesMapper.toSnapshot({ + config: { + name: 'Manual Glances', + haSensorData: { + cpu: { cpu_use_percent: 7 }, + processcount: { process_total: 9 }, + }, + }, + online: true, + source: 'manual', + }); + const entities = GlancesMapper.toEntities(snapshot); + + expect(entities.find((entityArg) => entityArg.attributes?.key === 'cpu_use_percent')?.state).toEqual(7); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'process_total')?.state).toEqual(9); + expect(entities.some((entityArg) => entityArg.attributes?.key === 'memory_use_percent')).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/heos/test.heos.configflow.node.ts b/test/heos/test.heos.configflow.node.ts new file mode 100644 index 0000000..6e69336 --- /dev/null +++ b/test/heos/test.heos.configflow.node.ts @@ -0,0 +1,31 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HeosConfigFlow } from '../../ts/integrations/heos/index.js'; + +tap.test('creates HEOS config from discovered host and account options', async () => { + const flow = new HeosConfigFlow(); + const step = await flow.start({ + source: 'ssdp', + integrationDomain: 'heos', + host: '192.168.1.80', + name: 'Living Room HEOS', + manufacturer: 'Denon', + model: 'HEOS 7', + serialNumber: 'HEOS12345', + }, {}); + + expect(step.kind).toEqual('form'); + const done = await step.submit!({ username: 'listener@example.com', password: 'secret' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.80'); + expect(done.config?.port).toEqual(1255); + expect(done.config?.username).toEqual('listener@example.com'); +}); + +tap.test('requires a HEOS host for manual setup', async () => { + const flow = new HeosConfigFlow(); + const step = await flow.start({ source: 'manual', integrationDomain: 'heos' }, {}); + const result = await step.submit!({}); + expect(result.kind).toEqual('error'); +}); + +export default tap.start(); diff --git a/test/heos/test.heos.discovery.node.ts b/test/heos/test.heos.discovery.node.ts new file mode 100644 index 0000000..080d782 --- /dev/null +++ b/test/heos/test.heos.discovery.node.ts @@ -0,0 +1,56 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createHeosDiscoveryDescriptor } from '../../ts/integrations/heos/index.js'; + +tap.test('matches Home Assistant HEOS SSDP records', async () => { + const descriptor = createHeosDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + st: 'urn:schemas-denon-com:device:ACT-Denon:1', + usn: 'uuid:heos-123::urn:schemas-denon-com:device:ACT-Denon:1', + location: 'http://192.168.1.80:60006/upnp/desc/aios_device/aios_device.xml', + upnp: { + manufacturer: 'Denon', + modelName: 'HEOS 7', + serialNumber: 'HEOS12345', + friendlyName: 'Living Room HEOS', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('certain'); + expect(result.normalizedDeviceId).toEqual('HEOS12345'); + expect(result.candidate?.host).toEqual('192.168.1.80'); + expect(result.candidate?.port).toEqual(1255); +}); + +tap.test('matches HEOS zeroconf and manual candidates', async () => { + const descriptor = createHeosDiscoveryDescriptor(); + const mdns = await descriptor.getMatchers()[1].matches({ + name: 'Kitchen HEOS', + type: '_heos-audio._tcp.local.', + host: 'kitchen-heos.local', + port: 10101, + txt: { model: 'HEOS 5', serial: 'HEOS5678' }, + }, {}); + expect(mdns.matched).toBeTrue(); + expect(mdns.candidate?.port).toEqual(1255); + + const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.81', name: 'Manual HEOS' }, {}); + expect(manual.matched).toBeTrue(); + expect(manual.candidate?.host).toEqual('192.168.1.81'); + + const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {}); + expect(validation.matched).toBeTrue(); +}); + +tap.test('rejects unrelated SSDP records', async () => { + const descriptor = createHeosDiscoveryDescriptor(); + const result = await descriptor.getMatchers()[0].matches({ + st: 'urn:schemas-upnp-org:device:Printer:1', + location: 'http://192.168.1.90/device.xml', + upnp: { manufacturer: 'Brother', modelName: 'Printer' }, + }, {}); + expect(result.matched).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/heos/test.heos.mapper.node.ts b/test/heos/test.heos.mapper.node.ts new file mode 100644 index 0000000..1162de9 --- /dev/null +++ b/test/heos/test.heos.mapper.node.ts @@ -0,0 +1,104 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HeosMapper, type IHeosSnapshot } from '../../ts/integrations/heos/index.js'; + +const snapshot: IHeosSnapshot = { + system: { + host: '192.168.1.80', + currentHost: '192.168.1.80', + signedInUsername: 'listener@example.com', + isSignedIn: true, + }, + players: [{ + name: 'Living Room', + playerId: 101, + model: 'Denon HEOS 7', + serial: 'LR123', + version: '3.40.0', + ipAddress: '192.168.1.80', + network: 'wired', + state: 'play', + volume: 45, + muted: false, + repeat: 'on_all', + shuffle: true, + groupId: 201, + available: true, + nowPlayingMedia: { + type: 'station', + station: 'Jazz Radio', + albumId: 'fav-jazz', + mediaId: 'station-1', + sourceId: 3, + duration: 180000, + currentPosition: 30000, + supportedControls: ['play', 'pause', 'stop'], + }, + }, { + name: 'Kitchen', + playerId: 102, + model: 'Denon HEOS 5', + serial: 'KT456', + version: '3.40.0', + ipAddress: '192.168.1.81', + network: 'wifi', + state: 'pause', + volume: 25, + muted: true, + groupId: 201, + available: true, + nowPlayingMedia: { + type: 'station', + station: 'HDMI ARC', + mediaId: 'inputs/hdmi_arc_1', + sourceId: 1027, + }, + }], + groups: [{ + name: 'Living Room + Kitchen', + groupId: 201, + leadPlayerId: 101, + memberPlayerIds: [102], + volume: 40, + muted: false, + }], + favorites: { + 1: { sourceId: 1028, name: 'Jazz Radio', type: 'station', mediaId: 'fav-jazz', playable: true }, + }, + inputSources: [{ + sourceId: 1027, + name: 'HDMI ARC', + type: 'station', + mediaId: 'inputs/hdmi_arc_1', + playable: true, + }], +}; + +tap.test('maps HEOS players and system snapshot to devices', async () => { + const devices = HeosMapper.toDevices(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'heos.system.192_168_1_80')).toBeTrue(); + const player = devices.find((deviceArg) => deviceArg.id === 'heos.player.101'); + expect(player?.manufacturer).toEqual('Denon'); + expect(player?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue(); +}); + +tap.test('maps HEOS players groups sources and media entities', async () => { + const entities = HeosMapper.toEntities(snapshot); + const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room'); + const kitchen = entities.find((entityArg) => entityArg.id === 'media_player.kitchen'); + const sources = entities.find((entityArg) => entityArg.id === 'sensor.heos_system_sources'); + const groups = entities.find((entityArg) => entityArg.id === 'sensor.heos_system_groups'); + const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_heos_media'); + + expect(player?.state).toEqual('playing'); + expect(player?.attributes?.volumeLevel).toEqual(0.45); + expect(player?.attributes?.source).toEqual('Jazz Radio'); + expect(player?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']); + expect(player?.attributes?.mediaDuration).toEqual(180); + expect(player?.attributes?.mediaPosition).toEqual(30); + expect(kitchen?.attributes?.source).toEqual('HDMI ARC'); + expect(sources?.state).toEqual(2); + expect(groups?.state).toEqual(1); + expect(media?.state).toEqual('Jazz Radio'); +}); + +export default tap.start(); diff --git a/test/heos/test.heos.runtime.node.ts b/test/heos/test.heos.runtime.node.ts new file mode 100644 index 0000000..435bd8a --- /dev/null +++ b/test/heos/test.heos.runtime.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HeosIntegration, type IHeosRawCommandRequest, type IHeosSnapshot } from '../../ts/integrations/heos/index.js'; + +const snapshot: IHeosSnapshot = { + system: { host: '192.168.1.80', currentHost: '192.168.1.80' }, + players: [{ + name: 'Living Room', + playerId: 101, + model: 'Denon HEOS 7', + state: 'pause', + volume: 20, + groupId: 201, + available: true, + }, { + name: 'Kitchen', + playerId: 102, + model: 'Denon HEOS 5', + state: 'pause', + volume: 30, + groupId: 201, + available: true, + }], + groups: [{ name: 'Downstairs', groupId: 201, leadPlayerId: 101, memberPlayerIds: [102] }], + favorites: { + 1: { sourceId: 1028, name: 'Jazz Radio', type: 'station', mediaId: 'fav-jazz', playable: true }, + }, + inputSources: [{ sourceId: 1027, name: 'HDMI ARC', type: 'station', mediaId: 'inputs/hdmi_arc_1', playable: true }], +}; + +tap.test('models HEOS media player commands through an executor', async () => { + const commands: IHeosRawCommandRequest[] = []; + const runtime = await new HeosIntegration().setup({ + snapshot, + commandExecutor: { + execute: async (requestArg) => { + commands.push(requestArg); + return { command: requestArg.command, result: true, message: {} }; + }, + }, + }, {}); + + const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } }); + const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } }); + const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'Jazz Radio' } }); + const join = await runtime.callService!({ domain: 'media_player', service: 'join', target: { entityId: 'media_player.living_room' }, data: { group_members: ['media_player.kitchen'] } }); + const groupVolume = await runtime.callService!({ domain: 'heos', service: 'group_volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.5 } }); + + expect(play.success).toBeTrue(); + expect(volume.success).toBeTrue(); + expect(source.success).toBeTrue(); + expect(join.success).toBeTrue(); + expect(groupVolume.success).toBeTrue(); + expect(commands.map((commandArg) => commandArg.command)).toEqual([ + 'player/set_play_state', + 'player/set_volume', + 'browse/play_preset', + 'group/set_group', + 'group/set_volume', + ]); + expect(commands[0].parameters).toEqual({ pid: 101, state: 'play' }); + expect(commands[1].parameters).toEqual({ pid: 101, level: 35 }); + expect(commands[2].parameters).toEqual({ pid: 101, preset: 1 }); + expect(commands[3].parameters).toEqual({ pid: '101,102' }); + expect(commands[4].parameters).toEqual({ gid: 201, level: 50 }); +}); + +tap.test('does not report live command success for static snapshots without transport', async () => { + const runtime = await new HeosIntegration().setup({ snapshot }, {}); + const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } }); + expect(result.success).toBeFalse(); + expect(result.error?.includes('config.host or commandExecutor')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/ipp/test.ipp.client.node.ts b/test/ipp/test.ipp.client.node.ts new file mode 100644 index 0000000..e906624 --- /dev/null +++ b/test/ipp/test.ipp.client.node.ts @@ -0,0 +1,135 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IppClient, IppIntegration } from '../../ts/integrations/ipp/index.js'; + +tap.test('reads IPP printer attributes with a native Get-Printer-Attributes request', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method?: string; body: Buffer }> = []; + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + calls.push({ url: String(urlArg), method: initArg?.method, body: Buffer.from(initArg?.body as ArrayBuffer) }); + return new Response(arrayBuffer(ippResponse([ + stringAttribute(0x42, 'printer-name', ['TCP Printer']), + stringAttribute(0x41, 'printer-info', ['Ready over IPP']), + stringAttribute(0x41, 'printer-make-and-model', ['Brother HL-L2350DW']), + stringAttribute(0x41, 'printer-device-id', ['MFG:Brother;MDL:HL-L2350DW;CMD:PCL;SERIALNUMBER:BR123;']), + stringAttribute(0x45, 'printer-uri-supported', ['ipp://printer.local:631/ipp/print']), + enumAttribute('printer-state', [4]), + stringAttribute(0x41, 'printer-state-message', ['Printing']), + stringAttribute(0x44, 'printer-state-reasons', ['none']), + booleanAttribute('printer-is-accepting-jobs', true), + integerAttribute('queued-job-count', 1), + stringAttribute(0x42, 'marker-names', ['Black Toner']), + stringAttribute(0x44, 'marker-types', ['toner-cartridge']), + integerAttribute('marker-levels', 73), + ])), { status: 200, headers: { 'content-type': 'application/ipp' } }); + }) as typeof globalThis.fetch; + + try { + const snapshot = await new IppClient({ host: 'printer.local', port: 631, basePath: '/ipp/print', timeoutMs: 1000 }).getSnapshot(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.printer.manufacturer).toEqual('Brother'); + expect(snapshot.printer.serialNumber).toEqual('BR123'); + expect(snapshot.status.printerState).toEqual('printing'); + expect(snapshot.status.queuedJobCount).toEqual(1); + expect(snapshot.markers[0].level).toEqual(73); + expect(calls[0].url).toEqual('http://printer.local:631/ipp/print'); + expect(calls[0].method).toEqual('POST'); + expect(calls[0].body.readUInt16BE(2)).toEqual(0x000b); + expect(calls[0].body.includes(Buffer.from('printer-uri'))).toBeTrue(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('read-only IPP runtime exposes snapshot and refuses unsupported writes', async () => { + const runtime = await new IppIntegration().setup({ + snapshot: { + printer: { id: 'snapshot-printer', name: 'Snapshot Printer' }, + status: { printerState: 'idle', stateReasons: [] }, + markers: [{ index: 0, name: 'Black Toner', kind: 'toner', level: 55 }], + jobs: [], + online: true, + }, + }, {}); + const snapshotResult = await runtime.callService?.({ domain: 'ipp', service: 'snapshot', target: {} }); + expect(snapshotResult?.success).toBeTrue(); + const unsupported = await runtime.callService?.({ domain: 'switch', service: 'turn_on', target: {} }); + expect(unsupported?.success).toBeFalse(); + expect((await runtime.entities()).find((entityArg) => entityArg.id === 'sensor.black_toner')?.state).toEqual(55); + await runtime.destroy(); +}); + +tap.test('refresh reports failure for configs without host, client, attributes, or snapshot', async () => { + const runtime = await new IppIntegration().setup({ name: 'No Source Printer' }, {}); + const result = await runtime.callService?.({ domain: 'ipp', service: 'refresh', target: {} }); + expect(result?.success).toBeFalse(); + await runtime.destroy(); +}); + +tap.test('refresh reports failed live IPP reads instead of faking success', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error('connection refused'); + }) as typeof globalThis.fetch; + + try { + const runtime = await new IppIntegration().setup({ host: 'printer.local', timeoutMs: 1000 }, {}); + const result = await runtime.callService?.({ domain: 'ipp', service: 'refresh', target: {} }); + expect(result?.success).toBeFalse(); + expect(result?.error).toEqual('connection refused'); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +const ippResponse = (attributesArg: Buffer[]): Buffer => { + return Buffer.concat([ + Buffer.from([0x01, 0x01]), + uint16(0x0000), + uint32(1), + Buffer.from([0x04]), + ...attributesArg, + Buffer.from([0x03]), + ]); +}; + +const stringAttribute = (tagArg: number, nameArg: string, valuesArg: string[]): Buffer => { + return Buffer.concat(valuesArg.map((valueArg, indexArg) => value(tagArg, indexArg === 0 ? nameArg : '', Buffer.from(valueArg, 'utf8')))); +}; + +const integerAttribute = (nameArg: string, valueArg: number): Buffer => { + const buffer = Buffer.alloc(4); + buffer.writeInt32BE(valueArg, 0); + return value(0x21, nameArg, buffer); +}; + +const enumAttribute = (nameArg: string, valuesArg: number[]): Buffer => { + return Buffer.concat(valuesArg.map((valueArg, indexArg) => { + const buffer = Buffer.alloc(4); + buffer.writeInt32BE(valueArg, 0); + return value(0x23, indexArg === 0 ? nameArg : '', buffer); + })); +}; + +const booleanAttribute = (nameArg: string, valueArg: boolean): Buffer => value(0x22, nameArg, Buffer.from([valueArg ? 1 : 0])); + +const value = (tagArg: number, nameArg: string, valueArg: Buffer): Buffer => { + const name = Buffer.from(nameArg, 'utf8'); + return Buffer.concat([Buffer.from([tagArg]), uint16(name.length), name, uint16(valueArg.length), valueArg]); +}; + +const uint16 = (valueArg: number): Buffer => { + const buffer = Buffer.alloc(2); + buffer.writeUInt16BE(valueArg, 0); + return buffer; +}; + +const uint32 = (valueArg: number): Buffer => { + const buffer = Buffer.alloc(4); + buffer.writeUInt32BE(valueArg, 0); + return buffer; +}; + +const arrayBuffer = (bufferArg: Buffer): ArrayBuffer => bufferArg.buffer.slice(bufferArg.byteOffset, bufferArg.byteOffset + bufferArg.byteLength) as ArrayBuffer; + +export default tap.start(); diff --git a/test/ipp/test.ipp.discovery.node.ts b/test/ipp/test.ipp.discovery.node.ts new file mode 100644 index 0000000..21552f3 --- /dev/null +++ b/test/ipp/test.ipp.discovery.node.ts @@ -0,0 +1,58 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IppConfigFlow, createIppDiscoveryDescriptor } from '../../ts/integrations/ipp/index.js'; + +tap.test('matches IPP and IPPS mDNS printer records', async () => { + const descriptor = createIppDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ipp-mdns-match'); + const result = await matcher!.matches({ + type: '_ipps._tcp.local.', + name: 'Office Printer._ipps._tcp.local.', + host: 'office-printer.local', + port: 631, + txt: { + rp: 'ipp/print', + UUID: 'printer-uuid-1', + ty: 'HP Color LaserJet Pro', + usb_MFG: 'HP', + usb_MDL: 'Color LaserJet Pro', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('printer-uuid-1'); + expect(result.candidate?.integrationDomain).toEqual('ipp'); + expect(result.candidate?.metadata?.basePath).toEqual('/ipp/print'); + expect(result.candidate?.metadata?.tls).toBeTrue(); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.confidence).toEqual('certain'); +}); + +tap.test('matches manual IPP URLs and carries defaults into config flow', async () => { + const descriptor = createIppDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ipp-manual-match'); + const match = await matcher!.matches({ host: 'ipps://printer.local:631/ipp/print', name: 'Manual Printer' }, {}); + expect(match.matched).toBeTrue(); + expect(match.candidate?.host).toEqual('printer.local'); + expect(match.candidate?.metadata?.basePath).toEqual('/ipp/print'); + expect(match.candidate?.metadata?.tls).toBeTrue(); + + const step = await new IppConfigFlow().start(match.candidate!, {}); + const done = await step.submit!({ host: 'printer.local' }); + expect(done.kind).toEqual('done'); + expect(done.config?.port).toEqual(631); + expect(done.config?.basePath).toEqual('/ipp/print'); + expect(done.config?.tls).toBeTrue(); +}); + +tap.test('rejects IPP-looking candidates without a usable source', async () => { + const descriptor = createIppDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate({ source: 'manual', integrationDomain: 'ipp', name: 'Printer without host' }, {}); + expect(validation.matched).toBeFalse(); + expect(validation.reason).toEqual('IPP candidate lacks host, snapshot, attributes, or client information.'); +}); + +export default tap.start(); diff --git a/test/ipp/test.ipp.mapper.node.ts b/test/ipp/test.ipp.mapper.node.ts new file mode 100644 index 0000000..8ce1e8a --- /dev/null +++ b/test/ipp/test.ipp.mapper.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { IppClient, IppMapper, type IIppAttributeRecord, type IIppSnapshot } from '../../ts/integrations/ipp/index.js'; + +const attributes: IIppAttributeRecord = { + 'printer-name': 'Office Printer', + 'printer-info': 'Office color printer', + 'printer-location': 'Office', + 'printer-make-and-model': 'HP Color LaserJet Pro', + 'printer-device-id': 'MFG:HP;MDL:Color LaserJet Pro;CMD:PCL,POSTSCRIPT;SERIALNUMBER:CN123456;', + 'printer-uuid': 'urn:uuid:printer-1', + 'printer-state': 3, + 'printer-state-message': 'Ready', + 'printer-state-reasons': ['none'], + 'printer-is-accepting-jobs': true, + 'queued-job-count': 2, + 'printer-up-time': 3600, + 'printer-current-time': '2026-01-01T01:00:00.000Z', + 'marker-names': ['Black Toner', 'Cyan Ink'], + 'marker-types': ['toner-cartridge', 'ink-cartridge'], + 'marker-colors': ['black', 'cyan'], + 'marker-levels': [88, 42], + 'marker-low-levels': [5, 10], + 'marker-high-levels': [100, 100], + 'job-id': [12], + 'job-name': ['Test Page'], + 'job-state': [5], + 'job-originating-user-name': ['phil'], +}; + +tap.test('maps IPP attributes to printer device, marker, status, and job sensors', async () => { + const snapshot = IppClient.attributesToSnapshot(attributes, { host: 'printer.local', port: 631, basePath: '/ipp/print' }, true); + expect(snapshot.printer.manufacturer).toEqual('HP'); + expect(snapshot.printer.serialNumber).toEqual('CN123456'); + expect(snapshot.status.printerState).toEqual('idle'); + expect(snapshot.status.bootedAt).toEqual('2026-01-01T00:00:00.000Z'); + expect(snapshot.markers[0].kind).toEqual('toner'); + expect(snapshot.markers[1].kind).toEqual('ink'); + expect(snapshot.jobs[0].state).toEqual('processing'); + + const devices = IppMapper.toDevices(snapshot); + const entities = IppMapper.toEntities(snapshot); + expect(devices[0].id).toEqual('ipp.printer.urn_uuid_printer_1'); + expect(devices[0].features.some((featureArg) => featureArg.id === 'marker_0')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_printer_status')?.state).toEqual('idle'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.black_toner')?.attributes?.markerKind).toEqual('toner'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.cyan_ink')?.state).toEqual(42); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_printer_queued_jobs')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.office_printer_accepting_jobs')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.test_page')?.attributes?.owner).toEqual('phil'); +}); + +tap.test('keeps hostless IPP runtime snapshots offline instead of faking live success', async () => { + const snapshot = await new IppClient({ name: 'Hostless Printer' }).getSnapshot(); + expect(snapshot.online).toBeFalse(); + expect(snapshot.source).toEqual('runtime'); + expect(await new IppClient({ name: 'Hostless Printer' }).ping()).toBeFalse(); +}); + +tap.test('maps offline snapshots without inventing marker or job values', async () => { + const snapshot: IIppSnapshot = { + printer: { id: 'offline-printer', name: 'Offline Printer' }, + status: { printerState: 'unknown', stateReasons: [] }, + markers: [], + jobs: [], + online: false, + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const entities = IppMapper.toEntities(snapshot); + expect(entities.find((entityArg) => entityArg.id === 'sensor.offline_printer_status')?.available).toBeFalse(); + expect(entities.some((entityArg) => entityArg.id.includes('marker'))).toBeFalse(); + expect(entities.some((entityArg) => entityArg.id.includes('job'))).toBeFalse(); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 791fc78..7bfb0f8 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -15,16 +15,22 @@ import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js'; import { AsuswrtIntegration } from './integrations/asuswrt/index.js'; import { BleboxIntegration } from './integrations/blebox/index.js'; import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js'; +import { BoschShcIntegration } from './integrations/bosch_shc/index.js'; import { BraviatvIntegration } from './integrations/braviatv/index.js'; import { BroadlinkIntegration } from './integrations/broadlink/index.js'; import { CastIntegration } from './integrations/cast/index.js'; import { DeconzIntegration } from './integrations/deconz/index.js'; import { DenonavrIntegration } from './integrations/denonavr/index.js'; +import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js'; import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js'; import { DsmrIntegration } from './integrations/dsmr/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js'; +import { FritzIntegration } from './integrations/fritz/index.js'; +import { GlancesIntegration } from './integrations/glances/index.js'; +import { HeosIntegration } from './integrations/heos/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; import { HomematicIntegration } from './integrations/homematic/index.js'; +import { IppIntegration } from './integrations/ipp/index.js'; import { JellyfinIntegration } from './integrations/jellyfin/index.js'; import { KnxIntegration } from './integrations/knx/index.js'; import { KodiIntegration } from './integrations/kodi/index.js'; @@ -71,17 +77,23 @@ export const integrations = [ new AxisIntegration(), new BleboxIntegration(), new BluetoothLeTrackerIntegration(), + new BoschShcIntegration(), new BraviatvIntegration(), new BroadlinkIntegration(), new CastIntegration(), new DeconzIntegration(), new DenonavrIntegration(), + new DevoloHomeNetworkIntegration(), new DlnaDmrIntegration(), new DsmrIntegration(), new EsphomeIntegration(), + new FritzIntegration(), + new GlancesIntegration(), + new HeosIntegration(), new HomekitControllerIntegration(), new HomematicIntegration(), new HueIntegration(), + new IppIntegration(), new JellyfinIntegration(), new KnxIntegration(), new KodiIntegration(), diff --git a/ts/integrations/bosch_shc/.generated-by-smarthome-exchange b/ts/integrations/bosch_shc/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/bosch_shc/.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/bosch_shc/bosch_shc.classes.client.ts b/ts/integrations/bosch_shc/bosch_shc.classes.client.ts new file mode 100644 index 0000000..2a8a050 --- /dev/null +++ b/ts/integrations/bosch_shc/bosch_shc.classes.client.ts @@ -0,0 +1,116 @@ +import type { + IBoschShcCommandContext, + IBoschShcConfig, + IBoschShcModeledCommand, + IBoschShcPairClientRequest, + IBoschShcPublicInformation, + IBoschShcSnapshot, +} from './bosch_shc.types.js'; + +export class BoschShcUnsupportedLiveOperationError extends Error { + constructor(actionArg: string) { + super(`Bosch SHC ${actionArg} requires config.snapshot or an injected executor. This native TypeScript port does not implement live TLS, certificate generation, or pairing transport.`); + this.name = 'BoschShcUnsupportedLiveOperationError'; + } +} + +export class BoschShcClient { + private readonly snapshot?: IBoschShcSnapshot; + + constructor(private readonly config: IBoschShcConfig) { + this.snapshot = config.snapshot ? this.clone(config.snapshot) : undefined; + } + + public async getSnapshot(): Promise { + if (this.snapshot) { + return this.normalizeSnapshot(this.clone(this.snapshot), 'snapshot'); + } + const result = await this.execute({ + action: 'read_snapshot', + method: 'GET', + path: '/smarthome', + reason: 'read_snapshot', + }); + if (!this.isSnapshot(result)) { + throw new Error('Bosch SHC snapshot executor must return an IBoschShcSnapshot object for read_snapshot.'); + } + return this.normalizeSnapshot(result, 'executor'); + } + + public async getPublicInformation(): Promise { + if (this.snapshot?.information) { + return this.clone(this.snapshot.information); + } + const result = await this.execute({ + action: 'get_public_information', + method: 'GET', + path: '/smarthome/public/information', + reason: 'get_public_information', + }); + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new Error('Bosch SHC public information executor must return an object.'); + } + return result as IBoschShcPublicInformation; + } + + public async pairClient(requestArg: IBoschShcPairClientRequest): Promise { + if (!requestArg.systemPassword) { + throw new Error('Bosch SHC pairing requires the controller system password.'); + } + return this.execute({ + action: 'pair_client', + method: 'POST', + path: '/smarthome/clients', + reason: 'pair_client', + body: { + clientId: requestArg.clientId || this.config.host || 'smarthome_exchange', + clientName: requestArg.clientName || 'smarthome.exchange', + certificatePem: requestArg.certificatePem, + systemPassword: requestArg.systemPassword, + }, + sensitiveFields: ['systemPassword', 'certificatePem'], + }); + } + + public async executeCommand(commandArg: IBoschShcModeledCommand, snapshotArg?: IBoschShcSnapshot): Promise { + return this.execute(commandArg, snapshotArg); + } + + public async destroy(): Promise {} + + private async execute(commandArg: IBoschShcModeledCommand, snapshotArg?: IBoschShcSnapshot): Promise { + const executor = this.config.executor; + if (!executor) { + throw new BoschShcUnsupportedLiveOperationError(commandArg.action); + } + const context: IBoschShcCommandContext = { + config: this.config, + snapshot: snapshotArg || this.snapshot, + }; + if (typeof executor === 'function') { + return executor(commandArg, context); + } + return executor.execute(commandArg, context); + } + + private normalizeSnapshot(snapshotArg: IBoschShcSnapshot, sourceArg: IBoschShcSnapshot['source']): IBoschShcSnapshot { + return { + ...snapshotArg, + devices: snapshotArg.devices || [], + host: snapshotArg.host || this.config.host || snapshotArg.information?.shcIpAddress, + uniqueId: snapshotArg.uniqueId || this.config.uniqueId || snapshotArg.information?.macAddress, + name: snapshotArg.name || this.config.name || this.config.hostname || snapshotArg.information?.shcIpAddress, + online: snapshotArg.online !== false, + source: snapshotArg.source || sourceArg, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + }; + } + + private isSnapshot(valueArg: unknown): valueArg is IBoschShcSnapshot { + return typeof valueArg === 'object' && valueArg !== null && 'devices' in valueArg; + } + + private clone(valueArg: TValue): TValue { + return JSON.parse(JSON.stringify(valueArg)) as TValue; + } +} diff --git a/ts/integrations/bosch_shc/bosch_shc.classes.configflow.ts b/ts/integrations/bosch_shc/bosch_shc.classes.configflow.ts new file mode 100644 index 0000000..d04db52 --- /dev/null +++ b/ts/integrations/bosch_shc/bosch_shc.classes.configflow.ts @@ -0,0 +1,84 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IBoschShcConfig, IBoschShcSnapshot } from './bosch_shc.types.js'; + +export class BoschShcConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Set up Bosch SHC', + description: 'Configure a local Bosch Smart Home Controller. Use an existing certificate/key or a snapshot; native pairing and live TLS transport require an injected executor.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'sslCertificate', label: 'Client certificate path or PEM', type: 'text' }, + { name: 'sslKey', label: 'Client key path or PEM', type: 'password' }, + { name: 'token', label: 'Registration token', type: 'text' }, + { name: 'hostname', label: 'Controller hostname from token', type: 'text' }, + { name: 'password', label: 'System password for executor-based pairing', type: 'password' }, + { name: 'snapshotJson', label: 'Controller snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => this.finish(valuesArg, candidateArg), + }; + } + + private async finish(valuesArg: Record, candidateArg: IDiscoveryCandidate): Promise> { + const host = this.stringValue(valuesArg.host) || candidateArg.host; + if (!host || host.includes('://')) { + return { kind: 'error', title: 'Invalid Bosch SHC host', error: 'Bosch SHC host must be a hostname or IP address without a URL scheme.' }; + } + + const sslCertificate = this.stringValue(valuesArg.sslCertificate); + const sslKey = this.stringValue(valuesArg.sslKey); + if (Boolean(sslCertificate) !== Boolean(sslKey)) { + return { kind: 'error', title: 'Incomplete Bosch SHC TLS credentials', error: 'Bosch SHC certificate and key must be provided together.' }; + } + + const snapshot = this.jsonValue(valuesArg.snapshotJson) + || this.objectValue(candidateArg.metadata?.snapshot); + const password = this.stringValue(valuesArg.password); + if (password && !sslCertificate && !snapshot) { + return { + kind: 'error', + title: 'Bosch SHC pairing requires an executor', + error: 'Native Bosch SHC pairing/certificate generation is not implemented here. Inject an executor/client or provide existing certificate/key credentials.', + }; + } + + return { + kind: 'done', + title: 'Bosch SHC configured', + config: { + host, + sslCertificate, + sslKey, + token: this.stringValue(valuesArg.token), + hostname: this.stringValue(valuesArg.hostname), + uniqueId: candidateArg.id || snapshot?.uniqueId || snapshot?.information?.macAddress, + name: candidateArg.name || snapshot?.name, + snapshot: snapshot ? { ...snapshot, host: snapshot.host || host } : undefined, + }, + }; + } + + 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) ? parsed as TValue : undefined; + } catch { + return undefined; + } + } + + private objectValue(valueArg: unknown): TValue | undefined { + return this.isRecord(valueArg) ? valueArg as TValue : undefined; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/bosch_shc/bosch_shc.classes.integration.ts b/ts/integrations/bosch_shc/bosch_shc.classes.integration.ts index a254a17..43b131a 100644 --- a/ts/integrations/bosch_shc/bosch_shc.classes.integration.ts +++ b/ts/integrations/bosch_shc/bosch_shc.classes.integration.ts @@ -1,28 +1,96 @@ -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 { BoschShcClient } from './bosch_shc.classes.client.js'; +import { BoschShcConfigFlow } from './bosch_shc.classes.configflow.js'; +import { createBoschShcDiscoveryDescriptor } from './bosch_shc.discovery.js'; +import { BoschShcMapper } from './bosch_shc.mapper.js'; +import type { IBoschShcConfig, IBoschShcPairClientRequest } from './bosch_shc.types.js'; -export class HomeAssistantBoschShcIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "bosch_shc", - displayName: "Bosch SHC", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/bosch_shc", - "upstreamDomain": "bosch_shc", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "boschshcpy==0.2.107" - ], - "dependencies": [], - "afterDependencies": [ - "zeroconf" - ], - "codeowners": [ - "@tschamm" - ] -}, - }); +export class BoschShcIntegration extends BaseIntegration { + public readonly domain = 'bosch_shc'; + public readonly displayName = 'Bosch SHC'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createBoschShcDiscoveryDescriptor(); + public readonly configFlow = new BoschShcConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/bosch_shc', + upstreamDomain: 'bosch_shc', + documentation: 'https://www.home-assistant.io/integrations/bosch_shc', + integrationType: 'hub', + iotClass: 'local_push', + requirements: ['boschshcpy==0.2.107'], + dependencies: [] as string[], + afterDependencies: ['zeroconf'], + codeowners: ['@tschamm'], + zeroconf: [{ name: 'bosch shc*', type: '_http._tcp.local.' }], + nativeLiveTransportImplemented: false, + }; + + public async setup(configArg: IBoschShcConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new BoschShcRuntime(new BoschShcClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantBoschShcIntegration extends BoschShcIntegration {} + +class BoschShcRuntime implements IIntegrationRuntime { + public domain = 'bosch_shc'; + + constructor(private readonly client: BoschShcClient) {} + + public async devices(): Promise { + return BoschShcMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return BoschShcMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'bosch_shc') { + return await this.callBoschService(requestArg); + } + + const snapshot = await this.client.getSnapshot(); + const command = BoschShcMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Bosch SHC service mapping: ${requestArg.domain}.${requestArg.service}` }; + } + if ('error' in command) { + return { success: false, error: command.error }; + } + const data = await this.client.executeCommand(command, snapshot); + 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(); + } + + private async callBoschService(requestArg: IServiceCallRequest): Promise { + if (!['pair_client', 'register_client'].includes(requestArg.service)) { + return { success: false, error: `Unsupported Bosch SHC integration service: ${requestArg.service}` }; + } + const systemPassword = requestArg.data?.system_password || requestArg.data?.password; + if (typeof systemPassword !== 'string' || !systemPassword) { + return { success: false, error: 'Bosch SHC pairing requires data.system_password.' }; + } + const request: IBoschShcPairClientRequest = { + systemPassword, + clientId: typeof requestArg.data?.client_id === 'string' ? requestArg.data.client_id : undefined, + clientName: typeof requestArg.data?.client_name === 'string' ? requestArg.data.client_name : undefined, + certificatePem: typeof requestArg.data?.certificate_pem === 'string' ? requestArg.data.certificate_pem : undefined, + }; + const data = await this.client.pairClient(request); + return { success: true, data }; } } diff --git a/ts/integrations/bosch_shc/bosch_shc.discovery.ts b/ts/integrations/bosch_shc/bosch_shc.discovery.ts new file mode 100644 index 0000000..60d7cf0 --- /dev/null +++ b/ts/integrations/bosch_shc/bosch_shc.discovery.ts @@ -0,0 +1,131 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IBoschShcManualEntry, IBoschShcMdnsRecord } from './bosch_shc.types.js'; +import { formatMac } from './bosch_shc.mapper.js'; + +const boschHttpType = '_http._tcp.local'; + +export class BoschShcMdnsMatcher implements IDiscoveryMatcher { + public id = 'bosch-shc-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Bosch Smart Home Controller zeroconf records named Bosch SHC on _http._tcp.local.'; + + public async matches(recordArg: IBoschShcMdnsRecord): Promise { + const type = normalizeMdnsType(recordArg.type); + const name = recordArg.name || ''; + const isBoschShc = type === boschHttpType && name.toLowerCase().startsWith('bosch shc'); + if (!isBoschShc) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Bosch SHC _http._tcp.local advertisement.' }; + } + + const macAddress = formatMac(extractMac(name) || stringValue(recordArg.txt?.mac) || stringValue(recordArg.txt?.id)); + const id = macAddress || recordArg.name; + const host = recordArg.host || recordArg.hostname?.replace(/\.$/, ''); + return { + matched: true, + confidence: 'certain', + reason: 'mDNS record matches Home Assistant Bosch SHC zeroconf criteria.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'bosch_shc', + id, + host, + port: recordArg.port, + name: nodeName(recordArg.hostname || recordArg.name), + manufacturer: 'Bosch', + model: 'Smart Home Controller', + macAddress, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + bosch_shc: true, + }, + }, + }; + } +} + +export class BoschShcManualMatcher implements IDiscoveryMatcher { + public id = 'bosch-shc-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Bosch SHC setup entries by selected integration, host, or Bosch metadata.'; + + public async matches(inputArg: IBoschShcManualEntry): Promise { + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const model = inputArg.model?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || manufacturer === 'bosch' && model.includes('smart home') || inputArg.metadata?.bosch_shc); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Bosch SHC setup hints.' }; + } + const id = formatMac(inputArg.macAddress || inputArg.id) || inputArg.id; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Bosch SHC host setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'bosch_shc', + id, + host: inputArg.host, + port: inputArg.port, + name: inputArg.name, + manufacturer: 'Bosch', + model: inputArg.model || 'Smart Home Controller', + macAddress: formatMac(inputArg.macAddress), + metadata: { ...inputArg.metadata, bosch_shc: true }, + }, + }; + } +} + +export class BoschShcCandidateValidator implements IDiscoveryValidator { + public id = 'bosch-shc-candidate-validator'; + public description = 'Validate Bosch SHC candidates before starting local controller setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'bosch_shc' + || Boolean(candidateArg.metadata?.bosch_shc) + || manufacturer === 'bosch' && (model.includes('smart home') || model.includes('shc')); + const hasHost = Boolean(candidateArg.host); + return { + matched: matched && hasHost, + confidence: matched && candidateArg.id ? 'certain' : matched && hasHost ? 'high' : matched ? 'medium' : 'low', + reason: matched + ? hasHost ? 'Candidate has Bosch SHC metadata and a usable local host.' : 'Candidate has Bosch SHC metadata but no local host.' + : 'Candidate is not Bosch SHC.', + candidate: matched && hasHost ? candidateArg : undefined, + normalizedDeviceId: formatMac(candidateArg.macAddress || candidateArg.id) || candidateArg.id, + }; + } +} + +export const createBoschShcDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'bosch_shc', + displayName: 'Bosch SHC', + }) + .addMatcher(new BoschShcMdnsMatcher()) + .addMatcher(new BoschShcManualMatcher()) + .addValidator(new BoschShcCandidateValidator()); +}; + +const normalizeMdnsType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, ''); + +const extractMac = (valueArg: string): string | undefined => { + const match = /\[([^\]]+)\]/.exec(valueArg); + return match?.[1]; +}; + +const nodeName = (valueArg?: string): string | undefined => { + const value = valueArg?.replace(/\.$/, '').replace(/\._http\._tcp\.local$/i, '').replace(/\.local$/i, '').trim(); + return value || undefined; +}; + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; diff --git a/ts/integrations/bosch_shc/bosch_shc.mapper.ts b/ts/integrations/bosch_shc/bosch_shc.mapper.ts new file mode 100644 index 0000000..9f45360 --- /dev/null +++ b/ts/integrations/bosch_shc/bosch_shc.mapper.ts @@ -0,0 +1,793 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js'; +import type { + IBoschShcBinarySensorFeature, + IBoschShcClimateFeature, + IBoschShcCoverFeature, + IBoschShcDevice, + IBoschShcDeviceService, + IBoschShcFeatureBase, + IBoschShcLightFeature, + IBoschShcModeledCommand, + IBoschShcSensorFeature, + IBoschShcSnapshot, + IBoschShcSwitchFeature, + TBoschShcCoverState, + TBoschShcFeature, +} from './bosch_shc.types.js'; + +const thermostatModels = new Set(['TRV', 'TRV_GEN2', 'TRV_GEN2_DUAL']); +const wallThermostatModels = new Set(['THB', 'BWTH', 'BWTH24', 'RTH2_BAT', 'RTH2_230']); +const shutterContactModels = new Set(['SWD', 'SWD2', 'SWD2_PLUS', 'SWD2_DUAL']); +const batteryModels = new Set([ + 'MD', + 'SWD', + 'SWD2', + 'SWD2_PLUS', + 'SWD2_DUAL', + 'SD', + 'SMOKE_DETECTOR2', + 'TRV', + 'TRV_GEN2', + 'TRV_GEN2_DUAL', + 'TWINGUARD', + 'WRC2', + 'SWITCH2', + 'THB', + 'BWTH', + 'BWTH24', + 'RTH2_BAT', + 'RTH2_230', + 'WLS', +]); +const smartPlugModels = new Set(['PSM', 'PLUG_COMPACT', 'PLUG_COMPACT_DUAL']); +const lightSwitchModels = new Set(['BSM', 'MICROMODULE_LIGHT_ATTACHED', 'MICROMODULE_RELAY']); +const lightModels = new Set(['LEDVANCE_LIGHT', 'HUE_LIGHT', 'MICROMODULE_DIMMER']); + +const sensorMetadata: Record = { + temperature: { name: 'Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + humidity: { name: 'Humidity', unit: '%', deviceClass: 'humidity' }, + valvetappet: { name: 'Valvetappet', unit: '%', stateClass: 'measurement' }, + purity: { name: 'Purity', unit: 'ppm' }, + airquality: { name: 'Air quality' }, + temperature_rating: { name: 'Temperature rating' }, + humidity_rating: { name: 'Humidity rating' }, + purity_rating: { name: 'Purity rating' }, + power: { name: 'Power', unit: 'W', deviceClass: 'power' }, + energy: { name: 'Energy', unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }, + communication_quality: { name: 'Communication quality' }, +}; + +export class BoschShcMapper { + public static toDevices(snapshotArg: IBoschShcSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const controllerId = this.controllerDeviceId(snapshotArg); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: controllerId, + integrationDomain: 'bosch_shc', + name: snapshotArg.name || snapshotArg.information?.shcIpAddress || 'Bosch Smart Home Controller', + protocol: 'http', + manufacturer: 'Bosch', + model: 'SmartHomeController', + online: snapshotArg.online !== false, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.online === false ? 'offline' : 'online', updatedAt }, + ], + metadata: { + host: snapshotArg.host || snapshotArg.information?.shcIpAddress, + uniqueId: this.controllerUniqueId(snapshotArg), + macAddress: snapshotArg.information?.macAddress, + softwareVersion: snapshotArg.information?.softwareUpdateState?.swInstalledVersion, + updateState: snapshotArg.information?.softwareUpdateState?.swUpdateState, + }, + }]; + + const featuresByDevice = new Map(); + for (const feature of this.features(snapshotArg)) { + const entries = featuresByDevice.get(feature.deviceId) || []; + entries.push(feature); + featuresByDevice.set(feature.deviceId, entries); + } + + for (const device of snapshotArg.devices) { + if (device.deleted) continue; + const featureEntries = featuresByDevice.get(device.id) || []; + const shxFeatures: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: this.available(device) ? 'online' : 'offline', updatedAt }, + ]; + + for (const feature of featureEntries) { + if (feature.platform === 'sensor') { + shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false, unit: feature.unit }); + state.push({ featureId: feature.id, value: feature.nativeValue, updatedAt }); + } else if (feature.platform === 'binary_sensor') { + shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false }); + state.push({ featureId: feature.id, value: feature.isOn, updatedAt }); + } else if (feature.platform === 'switch') { + shxFeatures.push({ id: feature.id, capability: 'switch', name: feature.name, readable: true, writable: true }); + state.push({ featureId: feature.id, value: feature.isOn, updatedAt }); + } else if (feature.platform === 'light') { + shxFeatures.push({ id: feature.id, capability: 'light', name: feature.name, readable: true, writable: true }); + state.push({ featureId: feature.id, value: feature.isOn, updatedAt }); + if (feature.supportsBrightness) { + shxFeatures.push({ id: `${feature.id}_brightness`, capability: 'light', name: `${feature.name} brightness`, readable: true, writable: true, unit: '%' }); + state.push({ featureId: `${feature.id}_brightness`, value: feature.brightness ?? null, updatedAt }); + } + } else if (feature.platform === 'cover') { + shxFeatures.push({ id: feature.id, capability: 'cover', name: feature.name, readable: true, writable: true, unit: '%' }); + state.push({ featureId: feature.id, value: feature.state, updatedAt }); + if (feature.position !== null) { + shxFeatures.push({ id: `${feature.id}_position`, capability: 'cover', name: `${feature.name} position`, readable: true, writable: true, unit: '%' }); + state.push({ featureId: `${feature.id}_position`, value: feature.position, updatedAt }); + } + } else if (feature.platform === 'climate') { + shxFeatures.push({ id: feature.id, capability: 'climate', name: feature.name, readable: true, writable: true }); + state.push({ featureId: feature.id, value: feature.hvacMode, updatedAt }); + if (feature.targetTemperature !== undefined) { + shxFeatures.push({ id: `${feature.id}_target_temperature`, capability: 'climate', name: `${feature.name} target temperature`, readable: true, writable: true, unit: 'C' }); + state.push({ featureId: `${feature.id}_target_temperature`, value: feature.targetTemperature, updatedAt }); + } + if (feature.currentTemperature !== undefined) { + shxFeatures.push({ id: `${feature.id}_current_temperature`, capability: 'climate', name: `${feature.name} current temperature`, readable: true, writable: false, unit: 'C' }); + state.push({ featureId: `${feature.id}_current_temperature`, value: feature.currentTemperature, updatedAt }); + } + } + } + + devices.push({ + id: this.deviceId(device), + integrationDomain: 'bosch_shc', + name: device.name || device.id, + protocol: 'http', + manufacturer: this.manufacturer(device), + model: device.deviceModel || 'Bosch SHC device', + online: this.available(device), + features: shxFeatures, + state, + metadata: { + rootDeviceId: device.rootDeviceId, + serial: device.serial, + profile: device.profile, + roomId: device.roomId, + status: device.status, + viaDevice: controllerId, + }, + }); + } + + return devices; + } + + public static toEntities(snapshotArg: IBoschShcSnapshot): IIntegrationEntity[] { + return this.features(snapshotArg).map((featureArg) => { + const base = { + id: this.entityId(featureArg), + uniqueId: featureArg.uniqueId, + integrationDomain: 'bosch_shc', + deviceId: this.deviceId(featureArg.device), + platform: featureArg.platform, + name: featureArg.name, + available: featureArg.available, + }; + + if (featureArg.platform === 'sensor') { + return { + ...base, + state: featureArg.nativeValue, + attributes: { + unit: featureArg.unit, + deviceClass: featureArg.deviceClass, + stateClass: featureArg.stateClass, + ...featureArg.attributes, + }, + }; + } + if (featureArg.platform === 'binary_sensor') { + return { + ...base, + state: featureArg.isOn ? 'on' : 'off', + attributes: { + deviceClass: featureArg.deviceClass, + ...featureArg.attributes, + }, + }; + } + if (featureArg.platform === 'switch') { + return { + ...base, + state: featureArg.isOn ? 'on' : 'off', + attributes: { + deviceClass: featureArg.deviceClass, + entityCategory: featureArg.entityCategory, + switchKind: featureArg.kind, + }, + }; + } + if (featureArg.platform === 'light') { + return { + ...base, + state: featureArg.isOn ? 'on' : 'off', + attributes: { + brightness: featureArg.brightness, + colorTemperature: featureArg.colorTemperature, + minColorTemperature: featureArg.minColorTemperature, + maxColorTemperature: featureArg.maxColorTemperature, + rgb: featureArg.rgb, + supportsBrightness: featureArg.supportsBrightness, + supportsColorTemperature: featureArg.supportsColorTemperature, + supportsRgb: featureArg.supportsRgb, + }, + }; + } + if (featureArg.platform === 'cover') { + return { + ...base, + state: featureArg.state, + attributes: { + currentPosition: featureArg.position, + deviceClass: featureArg.deviceClass, + operationState: featureArg.operationState, + calibrated: featureArg.calibrated, + supportsOpen: featureArg.supportsOpen, + supportsClose: featureArg.supportsClose, + supportsStop: featureArg.supportsStop, + supportsPosition: featureArg.supportsPosition, + }, + }; + } + return { + ...base, + state: featureArg.hvacMode, + attributes: { + currentTemperature: featureArg.currentTemperature, + targetTemperature: featureArg.targetTemperature, + operationMode: featureArg.operationMode, + boostMode: featureArg.boostMode, + low: featureArg.low, + summerMode: featureArg.summerMode, + supportsBoostMode: featureArg.supportsBoostMode, + supportedHvacModes: featureArg.supportedHvacModes, + }, + }; + }); + } + + public static features(snapshotArg: IBoschShcSnapshot): TBoschShcFeature[] { + return [ + ...this.sensorFeatures(snapshotArg), + ...this.binarySensorFeatures(snapshotArg), + ...this.switchFeatures(snapshotArg), + ...this.lightFeatures(snapshotArg), + ...this.coverFeatures(snapshotArg), + ...this.climateFeatures(snapshotArg), + ]; + } + + public static commandForService(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined { + if (requestArg.domain === 'switch') { + return this.switchCommand(snapshotArg, requestArg); + } + if (requestArg.domain === 'light') { + return this.lightCommand(snapshotArg, requestArg); + } + if (requestArg.domain === 'cover') { + return this.coverCommand(snapshotArg, requestArg); + } + if (requestArg.domain === 'climate') { + return this.climateCommand(snapshotArg, requestArg); + } + return undefined; + } + + public static controllerDeviceId(snapshotArg: IBoschShcSnapshot): string { + return `bosch_shc.controller.${this.slug(this.controllerUniqueId(snapshotArg) || snapshotArg.host || 'controller')}`; + } + + public static deviceId(deviceArg: IBoschShcDevice): string { + return `bosch_shc.device.${this.slug(deviceArg.id || deviceArg.serial || deviceArg.name || 'device')}`; + } + + public static entityId(featureArg: TBoschShcFeature): string { + const deviceSlug = this.slug(featureArg.device.name || featureArg.device.id || 'bosch_shc'); + const alias = this.slug(featureArg.alias); + const objectId = this.usesDefaultEntityObjectId(featureArg) ? deviceSlug : `${deviceSlug}_${alias}`; + return `${featureArg.platform}.${objectId}`; + } + + private static sensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSensorFeature[] { + const features: IBoschShcSensorFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const model = device.deviceModel || ''; + const temperature = this.service(snapshotArg, device, 'TemperatureLevel'); + if (temperature && (thermostatModels.has(model) || wallThermostatModels.has(model))) { + features.push(this.sensorFeature(device, 'temperature', this.numberState(temperature, 'temperature'), temperature)); + } + + const humidity = this.service(snapshotArg, device, 'HumidityLevel'); + if (humidity && wallThermostatModels.has(model)) { + features.push(this.sensorFeature(device, 'humidity', this.numberState(humidity, 'humidity'), humidity)); + } + + const valve = this.service(snapshotArg, device, 'ValveTappet'); + if (valve && thermostatModels.has(model)) { + features.push(this.sensorFeature(device, 'valvetappet', this.numberState(valve, 'position'), valve, { + valve_tappet_state: this.stringState(valve, 'value'), + })); + } + + const airQuality = this.service(snapshotArg, device, 'AirQualityLevel'); + if (airQuality && model === 'TWINGUARD') { + features.push(this.sensorFeature(device, 'temperature', this.numberState(airQuality, 'temperature'), airQuality)); + features.push(this.sensorFeature(device, 'humidity', this.numberState(airQuality, 'humidity'), airQuality)); + features.push(this.sensorFeature(device, 'purity', this.numberState(airQuality, 'purity'), airQuality)); + features.push(this.sensorFeature(device, 'airquality', this.stringState(airQuality, 'combinedRating'), airQuality, { + rating_description: this.stringState(airQuality, 'description'), + })); + features.push(this.sensorFeature(device, 'temperature_rating', this.stringState(airQuality, 'temperatureRating'), airQuality)); + features.push(this.sensorFeature(device, 'humidity_rating', this.stringState(airQuality, 'humidityRating'), airQuality)); + features.push(this.sensorFeature(device, 'purity_rating', this.stringState(airQuality, 'purityRating'), airQuality)); + } + + const powerMeter = this.service(snapshotArg, device, 'PowerMeter'); + if (powerMeter && (smartPlugModels.has(model) || model === 'BSM' || this.hasService(device, 'PowerSwitch'))) { + features.push(this.sensorFeature(device, 'power', this.numberState(powerMeter, 'powerConsumption'), powerMeter)); + const energyWh = this.numberState(powerMeter, 'energyConsumption'); + features.push(this.sensorFeature(device, 'energy', typeof energyWh === 'number' ? energyWh / 1000 : null, powerMeter)); + } + + const communicationQuality = this.service(snapshotArg, device, 'CommunicationQuality'); + if (communicationQuality && smartPlugModels.has(model)) { + features.push(this.sensorFeature(device, 'communication_quality', this.stringState(communicationQuality, 'quality'), communicationQuality)); + } + } + return features; + } + + private static binarySensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcBinarySensorFeature[] { + const features: IBoschShcBinarySensorFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const model = device.deviceModel || ''; + const shutterContact = this.service(snapshotArg, device, 'ShutterContact'); + if (shutterContact && shutterContactModels.has(model)) { + const profile = device.profile || ''; + features.push({ + ...this.featureBase(device, 'binary_sensor', 'contact', this.uniqueId(device, 'contact', true), device.name || 'Shutter contact'), + isOn: this.stringState(shutterContact, 'value') === 'OPEN', + deviceClass: profile === 'ENTRANCE_DOOR' || profile === 'FRENCH_WINDOW' ? 'door' : 'window', + attributes: { profile }, + }); + } + + const battery = this.service(snapshotArg, device, 'BatteryLevel'); + if (battery && (batteryModels.has(model) || this.hasService(device, 'BatteryLevel'))) { + const level = this.batteryWarningLevel(battery); + features.push({ + ...this.featureBase(device, 'binary_sensor', 'battery', this.uniqueId(device, 'battery'), `${device.name || 'Bosch SHC device'} battery`), + isOn: level !== 'OK', + deviceClass: 'battery', + attributes: { warningLevel: level }, + }); + } + + const leakage = this.service(snapshotArg, device, 'WaterLeakageSensor'); + if (leakage) { + features.push({ + ...this.featureBase(device, 'binary_sensor', 'water_leakage', this.uniqueId(device, 'water_leakage'), `${device.name || 'Bosch SHC device'} water leakage`), + isOn: this.stringState(leakage, 'state') === 'LEAKAGE_DETECTED', + deviceClass: 'moisture', + }); + } + } + return features; + } + + private static switchFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSwitchFeature[] { + const features: IBoschShcSwitchFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const model = device.deviceModel || ''; + const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch'); + if (powerSwitch && !lightModels.has(model)) { + const isOutlet = smartPlugModels.has(model); + features.push({ + ...this.featureBase(device, 'switch', 'power', this.uniqueId(device, 'power', true), device.name || 'Bosch SHC switch'), + kind: 'power_switch', + isOn: this.stringState(powerSwitch, 'switchState') === 'ON', + deviceClass: isOutlet ? 'outlet' : 'switch', + serviceId: 'PowerSwitch', + stateKey: 'switchState', + onValue: 'ON', + offValue: 'OFF', + }); + } + + const routing = this.service(snapshotArg, device, 'Routing'); + if (routing && model === 'PSM') { + features.push({ + ...this.featureBase(device, 'switch', 'routing', this.uniqueId(device, 'routing'), `${device.name || 'Bosch SHC device'} routing`), + kind: 'routing', + isOn: this.stringState(routing, 'value') === 'ENABLED', + deviceClass: 'switch', + serviceId: 'Routing', + stateKey: 'value', + onValue: 'ENABLED', + offValue: 'DISABLED', + entityCategory: 'config', + }); + } + + const cameraLight = this.service(snapshotArg, device, 'CameraLight'); + if (cameraLight && model === 'CAMERA_EYES') { + features.push({ + ...this.featureBase(device, 'switch', 'camera_light', this.uniqueId(device, 'camera_light'), `${device.name || 'Bosch SHC camera'} light`), + kind: 'camera_light', + isOn: this.stringState(cameraLight, 'value') === 'ON', + deviceClass: 'switch', + serviceId: 'CameraLight', + stateKey: 'value', + onValue: 'ON', + offValue: 'OFF', + }); + } + + const privacy = this.service(snapshotArg, device, 'PrivacyMode'); + if (privacy && model === 'CAMERA_360') { + features.push({ + ...this.featureBase(device, 'switch', 'privacy_mode', this.uniqueId(device, 'privacy_mode'), `${device.name || 'Bosch SHC camera'} privacy mode`), + kind: 'privacy_mode', + isOn: this.stringState(privacy, 'value') === 'DISABLED', + deviceClass: 'switch', + serviceId: 'PrivacyMode', + stateKey: 'value', + onValue: 'DISABLED', + offValue: 'ENABLED', + }); + } + } + return features.filter((featureArg) => lightSwitchModels.has(featureArg.device.deviceModel || '') || smartPlugModels.has(featureArg.device.deviceModel || '') || featureArg.kind !== 'power_switch'); + } + + private static lightFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcLightFeature[] { + const features: IBoschShcLightFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const model = device.deviceModel || ''; + if (!lightModels.has(model) && !this.service(snapshotArg, device, 'BinarySwitch')) continue; + + const binarySwitch = this.service(snapshotArg, device, 'BinarySwitch'); + const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch'); + const switchService = binarySwitch || powerSwitch; + if (!switchService) continue; + + const brightness = this.service(snapshotArg, device, 'MultiLevelSwitch'); + const colorTemperature = this.service(snapshotArg, device, 'HueColorTemperature'); + const rgb = this.service(snapshotArg, device, 'HSBColorActuator'); + features.push({ + ...this.featureBase(device, 'light', 'light', this.uniqueId(device, 'light', true), device.name || 'Bosch SHC light'), + isOn: binarySwitch ? this.booleanState(binarySwitch, 'on') ?? null : this.stringState(switchService, 'switchState') === 'ON', + switchServiceId: binarySwitch ? 'BinarySwitch' : 'PowerSwitch', + brightness: brightness ? this.numberState(brightness, 'level') : null, + colorTemperature: colorTemperature ? this.numberState(colorTemperature, 'colorTemperature') : null, + minColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'minCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'minCt') : null, + maxColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'maxCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'maxCt') : null, + rgb: rgb ? this.numberState(rgb, 'rgb') : null, + supportsBrightness: Boolean(brightness), + supportsColorTemperature: Boolean(colorTemperature), + supportsRgb: Boolean(rgb), + }); + } + return features; + } + + private static coverFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcCoverFeature[] { + const features: IBoschShcCoverFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const shutter = this.service(snapshotArg, device, 'ShutterControl'); + if (!shutter) continue; + const level = this.numberState(shutter, 'level'); + const position = typeof level === 'number' ? Math.round(level * 100) : null; + const operationState = this.stringState(shutter, 'operationState'); + const model = device.deviceModel || ''; + features.push({ + ...this.featureBase(device, 'cover', 'cover', this.uniqueId(device, 'cover', true), device.name || 'Bosch SHC cover'), + serviceId: 'ShutterControl', + deviceClass: model === 'MICROMODULE_AWNING' ? 'awning' : model === 'MICROMODULE_BLINDS' ? 'blind' : 'shutter', + state: this.coverState(operationState, position), + position, + operationState: operationState ?? undefined, + calibrated: this.booleanState(shutter, 'calibrated'), + supportsOpen: true, + supportsClose: true, + supportsStop: true, + supportsPosition: true, + }); + } + return features; + } + + private static climateFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcClimateFeature[] { + const features: IBoschShcClimateFeature[] = []; + for (const device of this.activeDevices(snapshotArg)) { + const roomClimate = this.service(snapshotArg, device, 'RoomClimateControl'); + const heatingCircuit = this.service(snapshotArg, device, 'HeatingCircuit'); + const climateService = roomClimate || heatingCircuit; + if (!climateService) continue; + + const temperature = this.service(snapshotArg, device, 'TemperatureLevel'); + const summerMode = roomClimate ? this.booleanState(roomClimate, 'summerMode') : undefined; + features.push({ + ...this.featureBase(device, 'climate', 'climate', this.uniqueId(device, 'climate', true), device.name || 'Bosch SHC climate'), + serviceId: roomClimate ? 'RoomClimateControl' : 'HeatingCircuit', + hvacMode: summerMode === true ? 'off' : 'heat', + currentTemperature: temperature ? this.numberState(temperature, 'temperature') : null, + targetTemperature: this.numberState(climateService, 'setpointTemperature'), + operationMode: this.stringState(climateService, 'operationMode') ?? undefined, + boostMode: roomClimate ? this.booleanState(roomClimate, 'boostMode') : undefined, + low: roomClimate ? this.booleanState(roomClimate, 'low') : undefined, + summerMode, + supportsBoostMode: roomClimate ? this.booleanState(roomClimate, 'supportsBoostMode') : undefined, + supportedHvacModes: ['heat', 'off'], + }); + } + return features; + } + + private static switchCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined { + if (!['turn_on', 'turn_off'].includes(requestArg.service)) { + return { error: `Unsupported Bosch SHC switch service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'switch') as IBoschShcSwitchFeature | undefined; + if (!feature) { + return { error: 'Bosch SHC switch service calls require a switch entity target, or an unambiguous switch device target.' }; + } + const service = this.service(snapshotArg, feature.device, feature.serviceId); + if (!service) return { error: `Bosch SHC switch service ${feature.serviceId} is missing from snapshot.` }; + const value = requestArg.service === 'turn_on' ? feature.onValue : feature.offValue; + return this.putServiceState(feature.device, service, { [feature.stateKey]: value }, requestArg); + } + + private static lightCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined { + if (!['turn_on', 'turn_off'].includes(requestArg.service)) { + return { error: `Unsupported Bosch SHC light service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'light') as IBoschShcLightFeature | undefined; + if (!feature) { + return { error: 'Bosch SHC light service calls require a light entity target, or an unambiguous light device target.' }; + } + + const switchService = this.service(snapshotArg, feature.device, feature.switchServiceId); + if (!switchService) return { error: `Bosch SHC light service ${feature.switchServiceId} is missing from snapshot.` }; + + const patch: Record = feature.switchServiceId === 'BinarySwitch' + ? { on: requestArg.service === 'turn_on' } + : { switchState: requestArg.service === 'turn_on' ? 'ON' : 'OFF' }; + if (requestArg.service === 'turn_on') { + const brightness = requestArg.data?.brightness; + const brightnessPct = requestArg.data?.brightness_pct; + if (brightness !== undefined && !this.isByte(brightness)) { + return { error: 'Bosch SHC light brightness must be an integer between 0 and 255.' }; + } + if (brightnessPct !== undefined && !this.isPercent(brightnessPct)) { + return { error: 'Bosch SHC light brightness_pct must be an integer between 0 and 100.' }; + } + if (brightness !== undefined || brightnessPct !== undefined) { + const brightnessService = this.service(snapshotArg, feature.device, 'MultiLevelSwitch'); + if (!brightnessService) return { error: 'Bosch SHC light brightness is not supported by this device.' }; + const level = typeof brightnessPct === 'number' ? brightnessPct : Math.round((brightness as number) * 100 / 255); + return this.putServiceState(feature.device, brightnessService, { level }, requestArg); + } + } + return this.putServiceState(feature.device, switchService, patch, requestArg); + } + + private static coverCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined { + const supported = ['open_cover', 'close_cover', 'stop_cover', 'set_cover_position']; + if (!supported.includes(requestArg.service)) { + return { error: `Unsupported Bosch SHC cover service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'cover') as IBoschShcCoverFeature | undefined; + if (!feature) { + return { error: 'Bosch SHC cover service calls require a cover entity target, or an unambiguous cover device target.' }; + } + const service = this.service(snapshotArg, feature.device, 'ShutterControl'); + if (!service) return { error: 'Bosch SHC ShutterControl service is missing from snapshot.' }; + + if (requestArg.service === 'open_cover') return this.putServiceState(feature.device, service, { level: 1.0 }, requestArg); + if (requestArg.service === 'close_cover') return this.putServiceState(feature.device, service, { level: 0.0 }, requestArg); + if (requestArg.service === 'stop_cover') return this.putServiceState(feature.device, service, { operationState: 'STOPPED' }, requestArg); + const position = requestArg.data?.position; + if (!this.isPercent(position)) { + return { error: 'Bosch SHC set_cover_position requires data.position as an integer between 0 and 100.' }; + } + return this.putServiceState(feature.device, service, { level: position / 100 }, requestArg); + } + + private static climateCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined { + if (requestArg.service !== 'set_temperature') { + return { error: `Unsupported Bosch SHC climate service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'climate') as IBoschShcClimateFeature | undefined; + if (!feature) { + return { error: 'Bosch SHC climate service calls require a climate entity target, or an unambiguous climate device target.' }; + } + const temperature = requestArg.data?.temperature; + if (typeof temperature !== 'number' || !Number.isFinite(temperature)) { + return { error: 'Bosch SHC set_temperature requires data.temperature as a finite number.' }; + } + const service = this.service(snapshotArg, feature.device, feature.serviceId); + if (!service) return { error: `Bosch SHC climate service ${feature.serviceId} is missing from snapshot.` }; + return this.putServiceState(feature.device, service, { setpointTemperature: temperature }, requestArg); + } + + private static resolveFeature(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest, platformArg: TBoschShcFeature['platform']): TBoschShcFeature | undefined { + const features = this.features(snapshotArg).filter((featureArg) => featureArg.platform === platformArg); + if (requestArg.target.entityId) { + const entities = this.toEntities(snapshotArg); + const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId); + return features.find((featureArg) => featureArg.uniqueId === entity?.uniqueId); + } + if (requestArg.target.deviceId) { + const deviceFeatures = features.filter((featureArg) => this.deviceId(featureArg.device) === requestArg.target.deviceId); + return deviceFeatures.length === 1 ? deviceFeatures[0] : undefined; + } + return undefined; + } + + private static putServiceState(deviceArg: IBoschShcDevice, serviceArg: IBoschShcDeviceService, patchArg: Record, requestArg: IServiceCallRequest): IBoschShcModeledCommand { + const stateType = typeof serviceArg.state?.['@type'] === 'string' ? serviceArg.state['@type'] : undefined; + return { + action: 'put_device_service_state', + method: 'PUT', + path: `/devices/${this.encodeDeviceId(deviceArg.id)}/services/${serviceArg.id}/state`, + body: stateType ? { '@type': stateType, ...patchArg } : patchArg, + deviceId: deviceArg.id, + serviceId: serviceArg.id, + domain: requestArg.domain, + service: requestArg.service, + }; + } + + private static sensorFeature(deviceArg: IBoschShcDevice, keyArg: string, valueArg: string | number | null, serviceArg: IBoschShcDeviceService, attributesArg: Record = {}): IBoschShcSensorFeature { + const metadata = sensorMetadata[keyArg] || { name: this.humanize(keyArg) }; + void serviceArg; + return { + ...this.featureBase(deviceArg, 'sensor', keyArg, this.uniqueId(deviceArg, keyArg), `${deviceArg.name || 'Bosch SHC device'} ${metadata.name.toLowerCase()}`), + nativeValue: valueArg, + unit: metadata.unit, + deviceClass: metadata.deviceClass, + stateClass: metadata.stateClass, + attributes: attributesArg, + }; + } + + private static featureBase(deviceArg: IBoschShcDevice, platformArg: TPlatform, aliasArg: string, uniqueIdArg: string, nameArg: string): IBoschShcFeatureBase & { platform: TPlatform } { + return { + platform: platformArg, + id: `${platformArg}_${this.slug(aliasArg)}`, + alias: aliasArg, + uniqueId: uniqueIdArg, + name: nameArg, + deviceId: deviceArg.id, + device: deviceArg, + available: this.available(deviceArg), + }; + } + + private static service(snapshotArg: IBoschShcSnapshot, deviceArg: IBoschShcDevice, serviceIdArg: string): IBoschShcDeviceService | undefined { + return deviceArg.services?.find((serviceArg) => serviceArg.id === serviceIdArg) + || snapshotArg.services?.find((serviceArg) => serviceArg.deviceId === deviceArg.id && serviceArg.id === serviceIdArg); + } + + private static activeDevices(snapshotArg: IBoschShcSnapshot): IBoschShcDevice[] { + return snapshotArg.devices.filter((deviceArg) => !deviceArg.deleted); + } + + private static hasService(deviceArg: IBoschShcDevice, serviceIdArg: string): boolean { + return Boolean(deviceArg.deviceServiceIds?.includes(serviceIdArg) || deviceArg.services?.some((serviceArg) => serviceArg.id === serviceIdArg)); + } + + private static stringState(serviceArg: IBoschShcDeviceService, keyArg: string): string | null { + const value = serviceArg.state?.[keyArg]; + return typeof value === 'string' ? value : null; + } + + private static numberState(serviceArg: IBoschShcDeviceService, keyArg: string): number | null { + const value = serviceArg.state?.[keyArg]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; + } + return null; + } + + private static numberNestedState(serviceArg: IBoschShcDeviceService, objectKeyArg: string, keyArg: string): number | null { + const record = asRecord(serviceArg.state?.[objectKeyArg]); + const value = record?.[keyArg]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } + + private static booleanState(serviceArg: IBoschShcDeviceService, keyArg: string): boolean | undefined { + const value = serviceArg.state?.[keyArg]; + return typeof value === 'boolean' ? value : undefined; + } + + private static batteryWarningLevel(serviceArg: IBoschShcDeviceService): string { + const entry = serviceArg.faults?.entries?.[0]; + return typeof entry?.type === 'string' ? entry.type : 'OK'; + } + + private static coverState(operationStateArg: string | null, positionArg: number | null): TBoschShcCoverState { + if (operationStateArg === 'OPENING') return 'opening'; + if (operationStateArg === 'CLOSING') return 'closing'; + if (positionArg === 0) return 'closed'; + if (typeof positionArg === 'number' && positionArg > 0) return 'open'; + if (operationStateArg === 'STOPPED') return 'stopped'; + return 'unknown'; + } + + private static available(deviceArg: IBoschShcDevice): boolean { + return deviceArg.status === undefined || deviceArg.status === 'AVAILABLE'; + } + + private static manufacturer(deviceArg: IBoschShcDevice): string { + const value = deviceArg.manufacturer || 'Bosch'; + return value.toUpperCase() === 'BOSCH' ? 'Bosch' : value; + } + + private static uniqueId(deviceArg: IBoschShcDevice, aliasArg: string, defaultArg = false): string { + const base = deviceArg.serial || deviceArg.id; + return defaultArg ? base : `${base}_${aliasArg}`; + } + + private static usesDefaultEntityObjectId(featureArg: TBoschShcFeature): boolean { + return featureArg.platform === 'binary_sensor' && featureArg.alias === 'contact' + || featureArg.platform === 'switch' && featureArg.alias === 'power' + || featureArg.platform === 'light' && featureArg.alias === 'light' + || featureArg.platform === 'cover' && featureArg.alias === 'cover' + || featureArg.platform === 'climate' && featureArg.alias === 'climate'; + } + + private static controllerUniqueId(snapshotArg: IBoschShcSnapshot): string | undefined { + return formatMac(snapshotArg.uniqueId || snapshotArg.information?.macAddress || '') || snapshotArg.host; + } + + private static encodeDeviceId(valueArg: string): string { + return valueArg.replace(/#/g, '%23'); + } + + private static isByte(valueArg: unknown): valueArg is number { + return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 255; + } + + private static isPercent(valueArg: unknown): valueArg is number { + return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 100; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bosch_shc'; + } + + private static humanize(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); + } +} + +export const formatMac = (valueArg?: string): string | undefined => { + if (!valueArg) return undefined; + const trimmed = valueArg.trim(); + if (/^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/i.test(trimmed)) return trimmed.toLowerCase(); + const compact = trimmed.replace(/[:.-]/g, '').toLowerCase(); + if (!/^[0-9a-f]{12}$/.test(compact)) return undefined; + return compact.match(/../g)?.join('-'); +}; + +const asRecord = (valueArg: unknown): Record | undefined => { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) + ? valueArg as Record + : undefined; +}; diff --git a/ts/integrations/bosch_shc/bosch_shc.types.ts b/ts/integrations/bosch_shc/bosch_shc.types.ts index c208913..9c0005f 100644 --- a/ts/integrations/bosch_shc/bosch_shc.types.ts +++ b/ts/integrations/bosch_shc/bosch_shc.types.ts @@ -1,4 +1,271 @@ -export interface IHomeAssistantBoschShcConfig { - // TODO: replace with the TypeScript-native config for bosch_shc. +export type TBoschShcSnapshotSource = 'snapshot' | 'executor'; + +export type TBoschShcEntityPlatform = + | 'sensor' + | 'binary_sensor' + | 'switch' + | 'light' + | 'climate' + | 'cover'; + +export type TBoschShcCommandAction = + | 'read_snapshot' + | 'get_public_information' + | 'put_device_service_state' + | 'post_domain_action' + | 'pair_client'; + +export type TBoschShcCommandMethod = 'GET' | 'PUT' | 'POST'; + +export type TBoschShcJsonValue = + | string + | number + | boolean + | null + | TBoschShcJsonValue[] + | { [key: string]: TBoschShcJsonValue | undefined }; + +export interface IBoschShcSoftwareUpdateState { + swInstalledVersion?: string; + swUpdateState?: string; [key: string]: unknown; } + +export interface IBoschShcPublicInformation { + '@type'?: string; + shcIpAddress?: string; + macAddress?: string; + softwareUpdateState?: IBoschShcSoftwareUpdateState; + [key: string]: unknown; +} + +export interface IBoschShcDevice { + '@type'?: 'device' | string; + rootDeviceId?: string; + id: string; + deviceServiceIds?: string[]; + manufacturer?: string; + roomId?: string; + deviceModel?: string; + serial?: string; + profile?: string; + name?: string; + status?: string; + parentDeviceId?: string; + childDeviceIds?: string[]; + deleted?: boolean; + services?: IBoschShcDeviceService[]; + [key: string]: unknown; +} + +export interface IBoschShcDeviceService { + '@type'?: 'DeviceServiceData' | string; + id: string; + deviceId: string; + path?: string; + state?: Record; + faults?: { + entries?: Array<{ + type?: string; + [key: string]: unknown; + }>; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface IBoschShcRoom { + id: string; + name?: string; + iconId?: string; + [key: string]: unknown; +} + +export interface IBoschShcIntrusionState { + '@type'?: 'systemState' | string; + systemAvailability?: { available?: boolean; [key: string]: unknown }; + armingState?: { state?: string; remainingTimeUntilArmed?: number; [key: string]: unknown }; + alarmState?: { value?: string; incidents?: unknown[]; [key: string]: unknown }; + activeConfigurationProfile?: { profileId?: string | number; [key: string]: unknown }; + securityGapState?: { securityGaps?: unknown[]; [key: string]: unknown }; + [key: string]: unknown; +} + +export interface IBoschShcSnapshot { + information?: IBoschShcPublicInformation; + authenticatedInformation?: Record; + devices: IBoschShcDevice[]; + services?: IBoschShcDeviceService[]; + rooms?: IBoschShcRoom[]; + intrusionDetection?: IBoschShcIntrusionState; + host?: string; + name?: string; + uniqueId?: string; + online?: boolean; + source?: TBoschShcSnapshotSource; + updatedAt?: string; +} + +export interface IBoschShcFeatureBase { + platform: TBoschShcEntityPlatform; + id: string; + alias: string; + uniqueId: string; + name: string; + deviceId: string; + device: IBoschShcDevice; + available: boolean; +} + +export interface IBoschShcSensorFeature extends IBoschShcFeatureBase { + platform: 'sensor'; + nativeValue: string | number | null; + unit?: string; + deviceClass?: string; + stateClass?: string; + attributes?: Record; +} + +export interface IBoschShcBinarySensorFeature extends IBoschShcFeatureBase { + platform: 'binary_sensor'; + isOn: boolean; + deviceClass?: 'battery' | 'door' | 'window' | 'moisture' | string; + attributes?: Record; +} + +export type TBoschShcSwitchKind = 'power_switch' | 'routing' | 'camera_light' | 'privacy_mode'; + +export interface IBoschShcSwitchFeature extends IBoschShcFeatureBase { + platform: 'switch'; + kind: TBoschShcSwitchKind; + isOn: boolean | null; + deviceClass?: 'outlet' | 'switch' | string; + serviceId: string; + stateKey: string; + onValue: unknown; + offValue: unknown; + entityCategory?: 'config'; +} + +export interface IBoschShcLightFeature extends IBoschShcFeatureBase { + platform: 'light'; + isOn: boolean | null; + switchServiceId: 'BinarySwitch' | 'PowerSwitch'; + brightness?: number | null; + colorTemperature?: number | null; + minColorTemperature?: number | null; + maxColorTemperature?: number | null; + rgb?: number | null; + supportsBrightness: boolean; + supportsColorTemperature: boolean; + supportsRgb: boolean; +} + +export type TBoschShcCoverState = 'opening' | 'closing' | 'open' | 'closed' | 'stopped' | 'unknown'; + +export interface IBoschShcCoverFeature extends IBoschShcFeatureBase { + platform: 'cover'; + serviceId: 'ShutterControl'; + deviceClass: 'shutter' | 'awning' | 'blind'; + state: TBoschShcCoverState; + position: number | null; + operationState?: string; + calibrated?: boolean; + supportsOpen: boolean; + supportsClose: boolean; + supportsStop: boolean; + supportsPosition: boolean; +} + +export interface IBoschShcClimateFeature extends IBoschShcFeatureBase { + platform: 'climate'; + serviceId: 'RoomClimateControl' | 'HeatingCircuit'; + hvacMode: 'heat' | 'off' | 'unknown'; + currentTemperature?: number | null; + targetTemperature?: number | null; + operationMode?: string; + boostMode?: boolean; + low?: boolean; + summerMode?: boolean; + supportsBoostMode?: boolean; + supportedHvacModes: Array<'heat' | 'off'>; +} + +export type TBoschShcFeature = + | IBoschShcSensorFeature + | IBoschShcBinarySensorFeature + | IBoschShcSwitchFeature + | IBoschShcLightFeature + | IBoschShcCoverFeature + | IBoschShcClimateFeature; + +export interface IBoschShcModeledCommand { + action: TBoschShcCommandAction; + method: TBoschShcCommandMethod; + path: string; + body?: Record | null; + deviceId?: string; + serviceId?: string; + domain?: string; + service?: string; + reason?: string; + sensitiveFields?: string[]; +} + +export interface IBoschShcCommandContext { + config: IBoschShcConfig; + snapshot?: IBoschShcSnapshot; +} + +export type TBoschShcCommandExecutor = + | ((commandArg: IBoschShcModeledCommand, contextArg: IBoschShcCommandContext) => Promise | unknown) + | { + execute(commandArg: IBoschShcModeledCommand, contextArg: IBoschShcCommandContext): Promise | unknown; + }; + +export interface IBoschShcPairClientRequest { + systemPassword: string; + clientId?: string; + clientName?: string; + certificatePem?: string; +} + +export interface IBoschShcConfig { + host?: string; + apiPort?: number; + publicPort?: number; + pairingPort?: number; + sslCertificate?: string; + sslKey?: string; + token?: string; + hostname?: string; + uniqueId?: string; + name?: string; + timeoutMs?: number; + snapshot?: IBoschShcSnapshot; + executor?: TBoschShcCommandExecutor; +} + +export interface IBoschShcMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + hostname?: string; + txt?: Record; + properties?: Record; + metadata?: Record; +} + +export interface IBoschShcManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + macAddress?: string; + metadata?: Record; +} + +export interface IHomeAssistantBoschShcConfig extends IBoschShcConfig {} diff --git a/ts/integrations/bosch_shc/index.ts b/ts/integrations/bosch_shc/index.ts index b835037..dfbf742 100644 --- a/ts/integrations/bosch_shc/index.ts +++ b/ts/integrations/bosch_shc/index.ts @@ -1,2 +1,6 @@ +export * from './bosch_shc.classes.client.js'; +export * from './bosch_shc.classes.configflow.js'; export * from './bosch_shc.classes.integration.js'; +export * from './bosch_shc.discovery.js'; +export * from './bosch_shc.mapper.js'; export * from './bosch_shc.types.js'; diff --git a/ts/integrations/devolo_home_network/.generated-by-smarthome-exchange b/ts/integrations/devolo_home_network/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/devolo_home_network/.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/devolo_home_network/devolo_home_network.classes.client.ts b/ts/integrations/devolo_home_network/devolo_home_network.classes.client.ts new file mode 100644 index 0000000..a116130 --- /dev/null +++ b/ts/integrations/devolo_home_network/devolo_home_network.classes.client.ts @@ -0,0 +1,115 @@ +import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js'; +import type { + IDevoloCommand, + IDevoloCommandResult, + IDevoloConfig, + IDevoloEvent, + IDevoloSnapshot, +} from './devolo_home_network.types.js'; + +type TDevoloEventHandler = (eventArg: IDevoloEvent) => void; + +export class DevoloHomeNetworkClient { + private currentSnapshot?: IDevoloSnapshot; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IDevoloConfig) {} + + public async getSnapshot(): Promise { + if (this.config.nativeClient?.getSnapshot) { + this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider'); + return this.cloneSnapshot(this.currentSnapshot); + } + + if (this.config.snapshotProvider) { + const snapshot = await this.config.snapshotProvider(); + if (snapshot) { + this.currentSnapshot = this.normalizeSnapshot(snapshot, 'provider'); + return this.cloneSnapshot(this.currentSnapshot); + } + } + + if (!this.currentSnapshot) { + this.currentSnapshot = DevoloHomeNetworkMapper.toSnapshot(this.config); + } + return this.cloneSnapshot(this.currentSnapshot); + } + + public onEvent(handlerArg: TDevoloEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async refresh(): Promise { + try { + this.currentSnapshot = undefined; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() }); + return { success: true, data: snapshot }; + } catch (errorArg) { + const error = errorArg instanceof Error ? errorArg.message : String(errorArg); + const snapshot = DevoloHomeNetworkMapper.toSnapshot({ ...this.config, snapshot: this.currentSnapshot }, false); + this.currentSnapshot = snapshot; + this.emit({ type: 'refresh_failed', error, data: snapshot, timestamp: Date.now() }); + return { success: false, error, data: snapshot }; + } + } + + public async sendCommand(commandArg: IDevoloCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, action: commandArg.action, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + + const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient); + if (!executor) { + const result: IDevoloCommandResult = { + success: false, + error: 'devolo Home Network live device commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for LED, guest Wi-Fi, WPS, restart, pairing, identify, or firmware update actions.', + data: { command: commandArg }, + }; + this.emit({ type: 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } + + try { + const result = this.commandResult(await executor(commandArg), commandArg); + this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } catch (errorArg) { + const result: IDevoloCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } }; + this.emit({ type: 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } + } + + public async destroy(): Promise { + await this.config.nativeClient?.destroy?.(); + this.eventHandlers.clear(); + } + + private normalizeSnapshot(snapshotArg: IDevoloSnapshot, sourceArg: IDevoloSnapshot['source']): IDevoloSnapshot { + const normalized = DevoloHomeNetworkMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected); + return { ...normalized, source: snapshotArg.source || sourceArg }; + } + + private commandResult(resultArg: unknown, commandArg: IDevoloCommand): IDevoloCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IDevoloCommandResult { + return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg); + } + + private emit(eventArg: IDevoloEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private cloneSnapshot(snapshotArg: TSnapshot): TSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as TSnapshot; + } +} + +export const DevoloClient = DevoloHomeNetworkClient; diff --git a/ts/integrations/devolo_home_network/devolo_home_network.classes.configflow.ts b/ts/integrations/devolo_home_network/devolo_home_network.classes.configflow.ts new file mode 100644 index 0000000..a7044d5 --- /dev/null +++ b/ts/integrations/devolo_home_network/devolo_home_network.classes.configflow.ts @@ -0,0 +1,115 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IDevoloConfig, IDevoloSnapshot } from './devolo_home_network.types.js'; +import { devoloHomeNetworkDefaultPort } from './devolo_home_network.types.js'; + +export class DevoloHomeNetworkConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + if (candidateArg.metadata?.homeControl === true) { + return { + kind: 'error', + title: 'Unsupported devolo Home Control device', + error: 'The devolo Home Control Central Unit is not supported by the devolo Home Network integration.', + }; + } + + const metadata = candidateArg.metadata || {}; + const host = candidateArg.host || this.stringValue(metadata.host) || ''; + const product = candidateArg.model || this.stringValue(metadata.product) || this.stringValue(metadata.Product) || ''; + const serialNumber = candidateArg.serialNumber || candidateArg.id || this.stringValue(metadata.serialNumber) || this.stringValue(metadata.SN) || ''; + + return { + kind: 'form', + title: 'Connect devolo Home Network device', + description: 'Provide a local device host. Password is only used when a live native client/executor is supplied; snapshot/manual setup remains read-only until an executor is configured.', + fields: [ + { name: 'host', label: host ? `IP address (${host})` : 'IP address', type: 'text', required: true }, + { name: 'port', label: `Port (${candidateArg.port || devoloHomeNetworkDefaultPort})`, type: 'number' }, + { name: 'password', label: 'Device password', type: 'password' }, + { name: 'name', label: candidateArg.name ? `Name (${candidateArg.name})` : 'Name', type: 'text' }, + { name: 'product', label: product ? `Product (${product})` : 'Product', type: 'text' }, + { name: 'serialNumber', label: serialNumber ? `Serial number (${serialNumber})` : 'Serial number', type: 'text' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => this.submit(candidateArg, valuesArg), + }; + } + + private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record): Promise> { + const snapshot = this.snapshotFromInput(valuesArg.snapshotJson ?? candidateArg.metadata?.snapshot); + if (snapshot instanceof Error) { + return { kind: 'error', title: 'Invalid snapshot', error: snapshot.message }; + } + + const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.device.host || snapshot?.device.ipAddress; + if (!host && !snapshot) { + return { kind: 'error', title: 'IP address required', error: 'devolo Home Network setup requires a host or a snapshot.' }; + } + + const metadata = candidateArg.metadata || {}; + const config: IDevoloConfig = { + host, + ipAddress: host, + port: this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.device.port || devoloHomeNetworkDefaultPort, + password: this.stringValue(valuesArg.password), + name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name, + product: this.stringValue(valuesArg.product) || candidateArg.model || this.stringValue(metadata.product) || this.stringValue(metadata.Product) || snapshot?.device.product, + model: candidateArg.model || this.stringValue(valuesArg.product) || snapshot?.device.model, + serialNumber: this.stringValue(valuesArg.serialNumber) || candidateArg.serialNumber || candidateArg.id || this.stringValue(metadata.SN) || snapshot?.device.serialNumber, + macAddress: candidateArg.macAddress || snapshot?.device.macAddress, + mtNumber: this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber) || snapshot?.device.mtNumber, + snapshot, + metadata: { + discoverySource: candidateArg.source, + discoveryMetadata: metadata, + liveHttpImplemented: false, + }, + }; + + return { + kind: 'done', + title: 'devolo Home Network device configured', + config, + }; + } + + private snapshotFromInput(valueArg: unknown): IDevoloSnapshot | undefined | Error { + if (this.isRecord(valueArg)) { + return valueArg as unknown as IDevoloSnapshot; + } + const text = this.stringValue(valueArg); + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as IDevoloSnapshot; + if (!parsed || !parsed.device || !Array.isArray(parsed.plcDevices) || !Array.isArray(parsed.wifiStations)) { + return new Error('Snapshot JSON must include device, plcDevices, and wifiStations fields.'); + } + return parsed; + } catch (errorArg) { + return errorArg instanceof Error ? errorArg : new Error(String(errorArg)); + } + } + + 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(valueArg); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} + +export const DevoloConfigFlow = DevoloHomeNetworkConfigFlow; diff --git a/ts/integrations/devolo_home_network/devolo_home_network.classes.integration.ts b/ts/integrations/devolo_home_network/devolo_home_network.classes.integration.ts index 320e5c0..90a4dbd 100644 --- a/ts/integrations/devolo_home_network/devolo_home_network.classes.integration.ts +++ b/ts/integrations/devolo_home_network/devolo_home_network.classes.integration.ts @@ -1,30 +1,73 @@ -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 { DevoloHomeNetworkClient } from './devolo_home_network.classes.client.js'; +import { DevoloHomeNetworkConfigFlow } from './devolo_home_network.classes.configflow.js'; +import { createDevoloHomeNetworkDiscoveryDescriptor } from './devolo_home_network.discovery.js'; +import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js'; +import type { IDevoloConfig } from './devolo_home_network.types.js'; +import { devoloHomeNetworkDomain } from './devolo_home_network.types.js'; -export class HomeAssistantDevoloHomeNetworkIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "devolo_home_network", - displayName: "devolo Home Network", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/devolo_home_network", - "upstreamDomain": "devolo_home_network", - "integrationType": "device", - "iotClass": "local_polling", - "qualityScale": "silver", - "requirements": [ - "devolo-plc-api==1.5.1" - ], - "dependencies": [ - "zeroconf" - ], - "afterDependencies": [], - "codeowners": [ - "@2Fake", - "@Shutgun" - ] -}, - }); +export class DevoloHomeNetworkIntegration extends BaseIntegration { + public readonly domain = devoloHomeNetworkDomain; + public readonly displayName = 'devolo Home Network'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createDevoloHomeNetworkDiscoveryDescriptor(); + public readonly configFlow = new DevoloHomeNetworkConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/devolo_home_network', + upstreamDomain: devoloHomeNetworkDomain, + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'silver', + requirements: ['devolo-plc-api==1.5.1'], + dependencies: ['zeroconf'], + afterDependencies: [] as string[], + codeowners: ['@2Fake', '@Shutgun'], + documentation: 'https://www.home-assistant.io/integrations/devolo_home_network', + mdnsType: '_dvl-deviceapi._tcp.local.', + liveHttpImplemented: false, + }; + + public async setup(configArg: IDevoloConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new DevoloHomeNetworkRuntime(new DevoloHomeNetworkClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantDevoloHomeNetworkIntegration extends DevoloHomeNetworkIntegration {} + +class DevoloHomeNetworkRuntime implements IIntegrationRuntime { + public domain = devoloHomeNetworkDomain; + + constructor(private readonly client: DevoloHomeNetworkClient) {} + + public async devices(): Promise { + return DevoloHomeNetworkMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return DevoloHomeNetworkMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(DevoloHomeNetworkMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = DevoloHomeNetworkMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported devolo Home Network service mapping: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/devolo_home_network/devolo_home_network.discovery.ts b/ts/integrations/devolo_home_network/devolo_home_network.discovery.ts new file mode 100644 index 0000000..296068c --- /dev/null +++ b/ts/integrations/devolo_home_network/devolo_home_network.discovery.ts @@ -0,0 +1,189 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js'; +import type { IDevoloManualDiscoveryRecord, IDevoloMdnsRecord } from './devolo_home_network.types.js'; +import { devoloHomeNetworkDefaultPort, devoloHomeNetworkDomain, devoloHomeNetworkMdnsType } from './devolo_home_network.types.js'; + +const homeControlMtNumbers = new Set(['2600', '2601']); +const devoloTextHints = ['devolo', 'dlan', 'magic', 'home network', 'wifi repeater', 'powerline']; + +export class DevoloHomeNetworkMdnsMatcher implements IDiscoveryMatcher { + public id = 'devolo-home-network-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize devolo Home Network mDNS device API advertisements.'; + + public async matches(recordArg: IDevoloMdnsRecord, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const txt = recordArg.txt || recordArg.properties || {}; + const type = (recordArg.type || recordArg.serviceType || '').toLowerCase(); + const product = this.txt(txt, 'Product') || this.txt(txt, 'product') || this.txt(txt, 'model'); + const mtNumber = this.txt(txt, 'MT') || this.txt(txt, 'mt'); + const serialNumber = this.txt(txt, 'SN') || this.txt(txt, 'sn'); + const host = recordArg.host || recordArg.addresses?.[0]; + const hostname = recordArg.hostname || recordArg.name; + const text = [type, recordArg.name, hostname, product] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const serviceMatched = type === devoloHomeNetworkMdnsType; + const textMatched = devoloTextHints.some((hintArg) => text.includes(hintArg)); + + if (homeControlMtNumbers.has(mtNumber || '')) { + return { + matched: false, + confidence: 'high', + reason: 'The devolo Home Control Central Unit is explicitly unsupported by the Home Assistant integration.', + metadata: { homeControl: true, mtNumber }, + }; + } + + if (!serviceMatched && !textMatched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a devolo Home Network device API advertisement.' }; + } + + const name = this.shortHostname(hostname) || product || 'devolo Home Network'; + return { + matched: true, + confidence: serviceMatched && serialNumber ? 'certain' : serviceMatched && host ? 'high' : 'medium', + reason: serviceMatched ? 'mDNS record advertises the devolo device API service.' : 'mDNS record contains devolo Home Network metadata.', + normalizedDeviceId: serialNumber || host || recordArg.name, + candidate: { + source: 'mdns', + integrationDomain: devoloHomeNetworkDomain, + id: serialNumber || recordArg.name || host, + host, + port: recordArg.port || devoloHomeNetworkDefaultPort, + name, + manufacturer: 'devolo', + model: product || mtNumber || 'devolo Home Network device', + serialNumber, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type || recordArg.serviceType, + txt, + Product: product, + SN: serialNumber, + MT: mtNumber, + }, + }, + metadata: { product, serialNumber, mtNumber }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()] || txtArg[keyArg.toLowerCase()]; + } + + private shortHostname(valueArg?: string): string | undefined { + return valueArg?.split('.')[0]; + } +} + +export class DevoloHomeNetworkManualMatcher implements IDiscoveryMatcher { + public id = 'devolo-home-network-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual devolo Home Network setup entries, including snapshot-only records.'; + + public async matches(inputArg: IDevoloManualDiscoveryRecord, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const metadata = inputArg.metadata || {}; + const host = inputArg.host || inputArg.ipAddress; + const manufacturer = inputArg.manufacturer || inputArg.brand; + const product = inputArg.product || inputArg.model; + const text = [inputArg.integrationDomain, manufacturer, product, inputArg.name, inputArg.hostname, metadata.product, metadata.model] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const mtNumber = inputArg.mtNumber || this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber); + const snapshot = inputArg.snapshot || metadata.snapshot; + const matched = inputArg.integrationDomain === devoloHomeNetworkDomain + || metadata.devolo === true + || Boolean(snapshot) + || Boolean(host && (!text || devoloTextHints.some((hintArg) => text.includes(hintArg)) || product || inputArg.serialNumber)); + + if (homeControlMtNumbers.has(mtNumber || '')) { + return { matched: false, confidence: 'high', reason: 'Manual entry is a devolo Home Control Central Unit, which is unsupported.', metadata: { homeControl: true, mtNumber } }; + } + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain devolo Home Network setup data.' }; + } + + const id = inputArg.id || inputArg.serialNumber || inputArg.macAddress || host || `snapshot-${Date.now()}`; + return { + matched: true, + confidence: snapshot ? 'certain' : host && (inputArg.serialNumber || product) ? 'high' : host ? 'medium' : 'low', + reason: snapshot ? 'Manual entry includes a devolo Home Network snapshot.' : 'Manual entry can start devolo Home Network setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: devoloHomeNetworkDomain, + id, + host, + port: inputArg.port || devoloHomeNetworkDefaultPort, + name: inputArg.name || inputArg.hostname || product || 'devolo Home Network', + manufacturer: 'devolo', + model: product || 'devolo Home Network device', + serialNumber: inputArg.serialNumber, + macAddress: DevoloHomeNetworkMapper.normalizeMac(inputArg.macAddress), + metadata: { + ...metadata, + devolo: true, + manual: true, + product, + mtNumber, + snapshot, + }, + }, + metadata: { snapshotConfigured: Boolean(snapshot), mtNumber }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } +} + +export class DevoloHomeNetworkCandidateValidator implements IDiscoveryValidator { + public id = 'devolo-home-network-candidate-validator'; + public description = 'Validate devolo Home Network discovery candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const metadata = candidateArg.metadata || {}; + const mtNumber = this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber); + if (homeControlMtNumbers.has(mtNumber || '') || metadata.homeControl === true) { + return { matched: false, confidence: 'high', reason: 'The candidate is a devolo Home Control Central Unit, which is unsupported.', metadata: { homeControl: true, mtNumber } }; + } + const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.product, metadata.Product] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const matched = candidateArg.integrationDomain === devoloHomeNetworkDomain + || metadata.devolo === true + || candidateArg.manufacturer?.toLowerCase() === 'devolo' + || devoloTextHints.some((hintArg) => text.includes(hintArg)) + || metadata.snapshot !== undefined; + + return { + matched, + confidence: matched && candidateArg.host && (candidateArg.serialNumber || metadata.SN) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has devolo Home Network metadata.' : 'Candidate is not a devolo Home Network device.', + candidate: matched ? { ...candidateArg, port: candidateArg.port || devoloHomeNetworkDefaultPort } : undefined, + normalizedDeviceId: candidateArg.serialNumber || this.stringValue(metadata.SN) || candidateArg.id || candidateArg.macAddress || candidateArg.host, + metadata: matched ? { mtNumber, liveHttpImplemented: false } : undefined, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } +} + +export const createDevoloHomeNetworkDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: devoloHomeNetworkDomain, displayName: 'devolo Home Network' }) + .addMatcher(new DevoloHomeNetworkMdnsMatcher()) + .addMatcher(new DevoloHomeNetworkManualMatcher()) + .addValidator(new DevoloHomeNetworkCandidateValidator()); +}; + +export const createDevoloDiscoveryDescriptor = createDevoloHomeNetworkDiscoveryDescriptor; diff --git a/ts/integrations/devolo_home_network/devolo_home_network.mapper.ts b/ts/integrations/devolo_home_network/devolo_home_network.mapper.ts new file mode 100644 index 0000000..5cddcfc --- /dev/null +++ b/ts/integrations/devolo_home_network/devolo_home_network.mapper.ts @@ -0,0 +1,1079 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IDevoloActionDescriptor, + IDevoloCommand, + IDevoloConfig, + IDevoloDeviceInfo, + IDevoloEvent, + IDevoloFirmwareUpdate, + IDevoloGuestWifiState, + IDevoloManualEntry, + IDevoloNeighborWifiNetwork, + IDevoloPlcDevice, + IDevoloPlcLink, + IDevoloSensorMap, + IDevoloSnapshot, + IDevoloSwitchMap, + IDevoloSwitchState, + IDevoloWifiStation, + TDevoloActionName, + TDevoloCommandType, + TDevoloFeature, +} from './devolo_home_network.types.js'; +import { devoloHomeNetworkDefaultPort, devoloHomeNetworkDomain } from './devolo_home_network.types.js'; + +const manufacturer = 'devolo'; + +const actionLabels: Record = { + identify: 'Identify device with a blinking LED', + pairing: 'Start PLC pairing', + restart: 'Restart device', + start_wps: 'Start WPS', + set_leds: 'Enable LEDs', + set_guest_wifi: 'Enable guest Wi-Fi', + install_firmware: 'Regular firmware', +}; + +const actionTypes: Record = { + identify: 'plc.identify', + pairing: 'plc.pair_device', + restart: 'device.restart', + start_wps: 'wifi.start_wps', + set_leds: 'device.set_leds', + set_guest_wifi: 'wifi.set_guest_access', + install_firmware: 'firmware.install', +}; + +export class DevoloHomeNetworkMapper { + public static toSnapshot(configArg: IDevoloConfig, connectedArg?: boolean, eventsArg: IDevoloEvent[] = []): IDevoloSnapshot { + const source = configArg.snapshot; + const manualSnapshots = (configArg.manualEntries || []) + .map((entryArg) => entryArg.snapshot) + .filter((snapshotArg): snapshotArg is IDevoloSnapshot => Boolean(snapshotArg)); + const manualData = this.mergeManualEntries(configArg.manualEntries || []); + const snapshotSources = [source, ...manualSnapshots].filter((snapshotArg): snapshotArg is IDevoloSnapshot => Boolean(snapshotArg)); + const plcDevices = this.uniquePlcDevices([ + ...snapshotSources.flatMap((snapshotArg) => snapshotArg.plcDevices || []), + ...(configArg.plcDevices || []), + ...(configArg.devices || []), + ...manualData.plcDevices, + ]); + const plcLinks = this.uniquePlcLinks([ + ...snapshotSources.flatMap((snapshotArg) => snapshotArg.plcLinks || []), + ...(configArg.plcLinks || []), + ...(configArg.links || []), + ...(configArg.dataRates || []), + ...manualData.plcLinks, + ]); + const wifiStations = this.uniqueWifiStations([ + ...snapshotSources.flatMap((snapshotArg) => snapshotArg.wifiStations || []), + ...(configArg.wifiStations || []), + ...(configArg.stations || []), + ...(configArg.clients || []), + ...manualData.wifiStations, + ]); + const neighboringWifiNetworks = this.uniqueNeighborNetworks([ + ...snapshotSources.flatMap((snapshotArg) => snapshotArg.neighboringWifiNetworks || []), + ...(configArg.neighboringWifiNetworks || []), + ...(configArg.neighbors || []), + ...manualData.neighboringWifiNetworks, + ]); + const device = this.deviceInfo(configArg, snapshotSources, manualData.device, plcDevices, plcLinks, wifiStations, neighboringWifiNetworks); + const switches = this.switches([ + ...snapshotSources.map((snapshotArg) => snapshotArg.switches), + manualData.switches, + configArg.switches, + ]); + const firmware = this.firmware([ + ...snapshotSources.map((snapshotArg) => snapshotArg.firmware), + manualData.firmware, + configArg.firmware, + this.firmwareFromDevice(device), + ]); + const sensors = this.sensorMap([ + ...snapshotSources.map((snapshotArg) => snapshotArg.sensors), + manualData.sensors, + configArg.sensors, + ], device, plcDevices, plcLinks, wifiStations, neighboringWifiNetworks); + const actions = this.uniqueActions([ + ...snapshotSources.flatMap((snapshotArg) => snapshotArg.actions || []), + ...manualData.actions, + ...(configArg.actions || []), + ...this.actionsFromSnapshot(device, plcDevices, plcLinks, wifiStations, switches, firmware), + ]); + const hasSnapshotData = Boolean(source || manualSnapshots.length || manualData.hasData || configArg.device || configArg.plcDevices?.length || configArg.devices?.length || configArg.plcLinks?.length || configArg.links?.length || configArg.dataRates?.length || configArg.wifiStations?.length || configArg.stations?.length || configArg.clients?.length || configArg.neighboringWifiNetworks?.length || configArg.neighbors?.length || configArg.firmware || configArg.sensors || configArg.switches); + + return { + connected: connectedArg ?? configArg.connected ?? source?.connected ?? (hasSnapshotData && source?.connected !== false), + source: source?.source || (hasSnapshotData ? 'manual' : 'runtime'), + updatedAt: source?.updatedAt || new Date().toISOString(), + device, + plcDevices, + plcLinks, + wifiStations, + neighboringWifiNetworks, + firmware, + sensors, + switches, + actions, + events: [...snapshotSources.flatMap((snapshotArg) => snapshotArg.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: this.cleanAttributes({ + ...snapshotSources.reduce((accArg, snapshotArg) => ({ ...accArg, ...snapshotArg.metadata }), {}), + ...configArg.metadata, + liveHttpImplemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }), + }; + } + + public static toDevices(snapshotArg: IDevoloSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.mainDevice(snapshotArg, updatedAt)]; + for (const device of snapshotArg.plcDevices) { + if (!this.isLocalPlcDevice(snapshotArg, device)) { + devices.push(this.plcPeerDevice(snapshotArg, device, updatedAt)); + } + } + for (const station of snapshotArg.wifiStations) { + devices.push(this.stationDevice(snapshotArg, station, updatedAt)); + } + return this.uniqueDeviceDefinitions(devices); + } + + public static toEntities(snapshotArg: IDevoloSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const deviceId = this.mainDeviceId(snapshotArg); + const deviceName = this.deviceName(snapshotArg.device); + entities.push(this.entity('binary_sensor', `${deviceName} Connected`, deviceId, `${this.uniqueBase(snapshotArg)}_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.device.host || snapshotArg.device.ipAddress, + port: snapshotArg.device.port || devoloHomeNetworkDefaultPort, + }, true)); + + const connectedToRouter = this.connectedToRouter(snapshotArg); + if (connectedToRouter !== undefined) { + entities.push(this.entity('binary_sensor', `${deviceName} Connected to router`, deviceId, `${this.uniqueBase(snapshotArg)}_connected_to_router`, connectedToRouter ? 'on' : 'off', usedIds, { + nativeKey: 'connected_to_router', + deviceClass: 'plug', + entityCategory: 'diagnostic', + }, snapshotArg.connected)); + } + + this.pushCoreSensors(entities, snapshotArg, usedIds); + this.pushSwitchEntities(entities, snapshotArg, usedIds); + this.pushButtonEntities(entities, snapshotArg, usedIds); + this.pushFirmwareEntity(entities, snapshotArg, usedIds); + this.pushPlcLinkEntities(entities, snapshotArg, usedIds); + this.pushStationEntities(entities, snapshotArg, usedIds); + + return entities; + } + + public static commandForService(snapshotArg: IDevoloSnapshot, requestArg: IServiceCallRequest): IDevoloCommand | undefined { + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + + if (requestArg.domain === devoloHomeNetworkDomain) { + const action = this.actionFromDomainService(requestArg.service); + if (!action) { + return undefined; + } + const enabled = action === 'set_leds' || action === 'set_guest_wifi' + ? this.booleanValue(requestArg.data?.enabled ?? requestArg.data?.state) + : undefined; + if ((action === 'set_leds' || action === 'set_guest_wifi') && enabled === undefined) { + return undefined; + } + return this.command(snapshotArg, requestArg, action, targetEntity, enabled, requestArg.data || {}); + } + + if (requestArg.domain === 'button' && requestArg.service === 'press') { + const action = this.actionValue(targetEntity?.attributes?.nativeAction); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) { + const action = this.actionValue(targetEntity?.attributes?.nativeAction); + if (action !== 'set_leds' && action !== 'set_guest_wifi') { + return undefined; + } + return this.command(snapshotArg, requestArg, action, targetEntity, requestArg.service === 'turn_on'); + } + + if (requestArg.domain === 'update' && (requestArg.service === 'install' || requestArg.service === 'update')) { + const action = this.actionValue(targetEntity?.attributes?.nativeAction) || 'install_firmware'; + return action === 'install_firmware' ? this.command(snapshotArg, requestArg, action, targetEntity, undefined, requestArg.data || {}) : undefined; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IDevoloEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' || eventArg.error ? 'error' : 'state_changed', + integrationDomain: devoloHomeNetworkDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + public static slug(valueArg: string | undefined): string { + return (valueArg || devoloHomeNetworkDomain).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || devoloHomeNetworkDomain; + } + + private static mergeManualEntries(entriesArg: IDevoloManualEntry[]): { + device?: IDevoloDeviceInfo; + plcDevices: IDevoloPlcDevice[]; + plcLinks: IDevoloPlcLink[]; + wifiStations: IDevoloWifiStation[]; + neighboringWifiNetworks: IDevoloNeighborWifiNetwork[]; + firmware?: IDevoloFirmwareUpdate; + sensors?: IDevoloSensorMap; + switches?: IDevoloSwitchMap; + actions: IDevoloActionDescriptor[]; + hasData: boolean; + } { + const plcDevices: IDevoloPlcDevice[] = []; + const plcLinks: IDevoloPlcLink[] = []; + const wifiStations: IDevoloWifiStation[] = []; + const neighboringWifiNetworks: IDevoloNeighborWifiNetwork[] = []; + const actions: IDevoloActionDescriptor[] = []; + const sensors: IDevoloSensorMap = {}; + const switches: IDevoloSwitchMap = {}; + let device: IDevoloDeviceInfo | undefined; + let firmware: IDevoloFirmwareUpdate | undefined; + let hasData = false; + + for (const entry of entriesArg) { + if (entry.device) { + device = { ...device, ...entry.device }; + hasData = true; + } else if (entry.host || entry.ipAddress || entry.name || entry.product || entry.model || entry.serialNumber || entry.macAddress) { + device = { + ...device, + id: entry.id || entry.serialNumber || entry.macAddress || entry.host || entry.ipAddress, + host: entry.host || entry.ipAddress, + ipAddress: entry.ipAddress || entry.host, + port: entry.port, + name: entry.name, + hostname: entry.hostname, + product: entry.product, + model: entry.model, + mtNumber: entry.mtNumber, + serialNumber: entry.serialNumber, + macAddress: this.normalizeMac(entry.macAddress), + firmwareVersion: entry.firmwareVersion, + features: entry.features, + metadata: entry.metadata, + }; + hasData = true; + } + + plcDevices.push(...(entry.plcDevices || []), ...(entry.devices || [])); + plcLinks.push(...(entry.plcLinks || []), ...(entry.links || []), ...(entry.dataRates || [])); + wifiStations.push(...(entry.wifiStations || []), ...(entry.stations || []), ...(entry.clients || [])); + neighboringWifiNetworks.push(...(entry.neighboringWifiNetworks || []), ...(entry.neighbors || [])); + Object.assign(sensors, entry.sensors || {}); + Object.assign(switches, entry.switches || {}); + if (entry.firmware) { + firmware = { ...firmware, ...entry.firmware }; + } + actions.push(...(entry.actions || [])); + hasData = hasData || Boolean(entry.snapshot || entry.plcDevices?.length || entry.devices?.length || entry.plcLinks?.length || entry.links?.length || entry.dataRates?.length || entry.wifiStations?.length || entry.stations?.length || entry.clients?.length || entry.neighboringWifiNetworks?.length || entry.neighbors?.length || entry.firmware || entry.sensors || entry.switches || entry.actions?.length); + } + + return { + device, + plcDevices, + plcLinks, + wifiStations, + neighboringWifiNetworks, + firmware, + sensors: Object.keys(sensors).length ? sensors : undefined, + switches: Object.keys(switches).length ? switches : undefined, + actions, + hasData, + }; + } + + private static deviceInfo(configArg: IDevoloConfig, snapshotsArg: IDevoloSnapshot[], manualDeviceArg: IDevoloDeviceInfo | undefined, plcDevicesArg: IDevoloPlcDevice[], plcLinksArg: IDevoloPlcLink[], wifiStationsArg: IDevoloWifiStation[], neighboringWifiNetworksArg: IDevoloNeighborWifiNetwork[]): IDevoloDeviceInfo { + const sourceDevice = snapshotsArg.find((snapshotArg) => snapshotArg.device)?.device; + const device = { + ...sourceDevice, + ...manualDeviceArg, + ...configArg.device, + }; + const host = configArg.host || configArg.ipAddress || device.host || device.ipAddress || sourceDevice?.host || sourceDevice?.ipAddress; + const port = configArg.port || device.port || (host ? devoloHomeNetworkDefaultPort : undefined); + const macAddress = this.normalizeMac(configArg.macAddress || device.macAddress || sourceDevice?.macAddress); + const features = this.uniqueStrings([...(sourceDevice?.features || []), ...(device.features || []), ...(configArg.features || [])]); + const plcnetApi = configArg.plcnetApi ?? device.plcnetApi ?? sourceDevice?.plcnetApi ?? Boolean(plcDevicesArg.length || plcLinksArg.length); + const deviceApi = configArg.deviceApi ?? device.deviceApi ?? sourceDevice?.deviceApi ?? Boolean(features.length || wifiStationsArg.length || neighboringWifiNetworksArg.length || configArg.firmware || configArg.switches); + const id = device.id || configArg.serialNumber || device.serialNumber || macAddress || host || 'device'; + + return this.cleanAttributes({ + ...device, + id, + host, + ipAddress: configArg.ipAddress || device.ipAddress || host, + port, + name: configArg.name || device.name || this.shortHostname(configArg.hostname || device.hostname) || configArg.product || device.product || 'devolo Home Network', + hostname: configArg.hostname || device.hostname, + product: configArg.product || device.product, + model: configArg.model || device.model || configArg.product || device.product, + mtNumber: configArg.mtNumber || device.mtNumber, + serialNumber: configArg.serialNumber || device.serialNumber, + macAddress, + firmwareVersion: configArg.firmwareVersion || device.firmwareVersion, + features, + deviceApi, + plcnetApi, + configurationUrl: device.configurationUrl || (host ? `http://${host}${port && port !== devoloHomeNetworkDefaultPort ? `:${port}` : ''}` : undefined), + metadata: { + ...sourceDevice?.metadata, + ...manualDeviceArg?.metadata, + ...configArg.device?.metadata, + }, + }) as IDevoloDeviceInfo; + } + + private static switches(sourcesArg: Array): IDevoloSnapshot['switches'] { + let leds: IDevoloSwitchState | undefined; + let guestWifi: IDevoloGuestWifiState | undefined; + for (const source of sourcesArg) { + if (!source) { + continue; + } + const sourceMap = source as IDevoloSwitchMap; + const ledValue = sourceMap.leds ?? sourceMap.switch_leds; + const guestValue = sourceMap.guestWifi ?? sourceMap.switch_guest_wifi; + if (ledValue !== undefined) { + leds = { ...leds, ...this.switchState(ledValue) }; + } + if (guestValue !== undefined) { + guestWifi = { ...guestWifi, ...this.guestWifiState(guestValue) }; + } + } + return this.cleanAttributes({ leds, guestWifi }) as IDevoloSnapshot['switches']; + } + + private static switchState(valueArg: unknown): IDevoloSwitchState { + if (typeof valueArg === 'boolean') { + return { enabled: valueArg, available: true }; + } + if (this.isRecord(valueArg)) { + return this.cleanAttributes({ + ...valueArg, + enabled: this.booleanValue(valueArg.enabled ?? valueArg.state ?? valueArg.on), + available: this.booleanValue(valueArg.available) ?? true, + }) as IDevoloSwitchState; + } + return {}; + } + + private static guestWifiState(valueArg: unknown): IDevoloGuestWifiState { + if (typeof valueArg === 'boolean') { + return { enabled: valueArg, available: true }; + } + if (this.isRecord(valueArg)) { + return this.cleanAttributes({ + ...valueArg, + enabled: this.booleanValue(valueArg.enabled ?? valueArg.state ?? valueArg.on), + ssid: this.stringValue(valueArg.ssid), + key: this.stringValue(valueArg.key || valueArg.password), + password: this.stringValue(valueArg.password || valueArg.key), + available: this.booleanValue(valueArg.available) ?? true, + }) as IDevoloGuestWifiState; + } + return {}; + } + + private static firmware(sourcesArg: Array): IDevoloFirmwareUpdate | undefined { + let firmware: IDevoloFirmwareUpdate | undefined; + for (const source of sourcesArg) { + if (source) { + firmware = { ...firmware, ...source }; + } + } + if (!firmware) { + return undefined; + } + const latestVersion = firmware.latestVersion || firmware.newFirmwareVersion; + const installedVersion = firmware.installedVersion || firmware.currentVersion; + return this.cleanAttributes({ + ...firmware, + installedVersion, + currentVersion: installedVersion, + latestVersion, + newFirmwareVersion: firmware.newFirmwareVersion || latestVersion, + available: firmware.available ?? Boolean(latestVersion && installedVersion && latestVersion !== installedVersion), + }) as IDevoloFirmwareUpdate; + } + + private static firmwareFromDevice(deviceArg: IDevoloDeviceInfo): IDevoloFirmwareUpdate | undefined { + return deviceArg.firmwareVersion ? { installedVersion: deviceArg.firmwareVersion, currentVersion: deviceArg.firmwareVersion } : undefined; + } + + private static sensorMap(sourcesArg: Array, deviceArg: IDevoloDeviceInfo, plcDevicesArg: IDevoloPlcDevice[], plcLinksArg: IDevoloPlcLink[], wifiStationsArg: IDevoloWifiStation[], neighboringWifiNetworksArg: IDevoloNeighborWifiNetwork[]): IDevoloSensorMap { + const sensors: IDevoloSensorMap = {}; + for (const source of sourcesArg) { + Object.assign(sensors, source || {}); + } + if (sensors.connected_plc_devices === undefined && (plcDevicesArg.length || plcLinksArg.length || deviceArg.plcnetApi)) { + const remoteDevices = plcDevicesArg.filter((plcDeviceArg) => !this.isLocalPlcByMac(plcDeviceArg, deviceArg.macAddress)); + sensors.connected_plc_devices = remoteDevices.length || this.uniqueStrings(plcLinksArg.map((linkArg) => this.linkPeerMac(linkArg, deviceArg.macAddress))).length; + } + if (sensors.connected_wifi_clients === undefined && (wifiStationsArg.length || this.hasFeature(deviceArg, 'wifi1'))) { + sensors.connected_wifi_clients = wifiStationsArg.filter((stationArg) => stationArg.connected !== false).length; + } + if (sensors.neighboring_wifi_networks === undefined && neighboringWifiNetworksArg.length) { + sensors.neighboring_wifi_networks = neighboringWifiNetworksArg.length; + } + const primaryLink = this.primaryPlcLink({ device: deviceArg, plcLinks: plcLinksArg } as IDevoloSnapshot); + if (primaryLink) { + sensors.plc_rx_rate ??= this.linkRxRate(primaryLink); + sensors.plc_tx_rate ??= this.linkTxRate(primaryLink); + } + if (sensors.last_restart === undefined && sensors.uptimeSeconds !== undefined) { + const uptime = this.numberValue(sensors.uptimeSeconds); + sensors.last_restart = uptime === undefined ? undefined : new Date(Date.now() - uptime * 1000).toISOString(); + } + return this.cleanAttributes(sensors) as IDevoloSensorMap; + } + + private static actionsFromSnapshot(deviceArg: IDevoloDeviceInfo, plcDevicesArg: IDevoloPlcDevice[], plcLinksArg: IDevoloPlcLink[], wifiStationsArg: IDevoloWifiStation[], switchesArg: IDevoloSnapshot['switches'], firmwareArg?: IDevoloFirmwareUpdate): IDevoloActionDescriptor[] { + const actions: IDevoloActionDescriptor[] = []; + if (deviceArg.plcnetApi || plcDevicesArg.length || plcLinksArg.length) { + actions.push(this.actionDescriptor('identify'), this.actionDescriptor('pairing')); + } + if (this.hasFeature(deviceArg, 'restart')) { + actions.push(this.actionDescriptor('restart')); + } + if (this.hasFeature(deviceArg, 'wifi1') || wifiStationsArg.length || switchesArg.guestWifi) { + actions.push(this.actionDescriptor('start_wps')); + } + if (this.hasFeature(deviceArg, 'led') || switchesArg.leds) { + actions.push(this.actionDescriptor('set_leds')); + } + if (this.hasFeature(deviceArg, 'wifi1') || switchesArg.guestWifi) { + actions.push(this.actionDescriptor('set_guest_wifi')); + } + if (this.hasFeature(deviceArg, 'update') || firmwareArg) { + actions.push(this.actionDescriptor('install_firmware')); + } + return actions; + } + + private static actionDescriptor(actionArg: TDevoloActionName): IDevoloActionDescriptor { + return { + action: actionArg, + type: actionTypes[actionArg], + target: actionArg === 'identify' || actionArg === 'pairing' ? 'plcnet' : actionArg === 'start_wps' || actionArg === 'set_guest_wifi' ? 'wifi' : actionArg === 'install_firmware' ? 'firmware' : 'device', + platform: actionArg === 'set_leds' || actionArg === 'set_guest_wifi' ? 'switch' : actionArg === 'install_firmware' ? 'update' : 'button', + available: true, + requiresPassword: actionArg !== 'identify' && actionArg !== 'pairing', + }; + } + + private static mainDevice(snapshotArg: IDevoloSnapshot, 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: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + ]; + + this.addFeatureState(features, state, 'connected_plc_devices', 'Connected PLC Devices', snapshotArg.sensors.connected_plc_devices, updatedAtArg); + this.addFeatureState(features, state, 'connected_wifi_clients', 'Connected Wi-Fi Clients', snapshotArg.sensors.connected_wifi_clients, updatedAtArg); + this.addFeatureState(features, state, 'neighboring_wifi_networks', 'Neighboring Wi-Fi Networks', snapshotArg.sensors.neighboring_wifi_networks, updatedAtArg); + this.addFeatureState(features, state, 'last_restart', 'Last Restart', snapshotArg.sensors.last_restart, updatedAtArg); + this.addFeatureState(features, state, 'firmware_version', 'Firmware Version', snapshotArg.firmware?.installedVersion || snapshotArg.device.firmwareVersion, updatedAtArg); + if (snapshotArg.switches.leds || this.hasAction(snapshotArg, 'set_leds')) { + features.push({ id: 'switch_leds', capability: 'switch', name: 'Enable LEDs', readable: true, writable: true }); + state.push({ featureId: 'switch_leds', value: snapshotArg.switches.leds?.enabled ?? null, updatedAt: updatedAtArg }); + } + if (snapshotArg.switches.guestWifi || this.hasAction(snapshotArg, 'set_guest_wifi')) { + features.push({ id: 'switch_guest_wifi', capability: 'switch', name: 'Enable guest Wi-Fi', readable: true, writable: true }); + state.push({ featureId: 'switch_guest_wifi', value: snapshotArg.switches.guestWifi?.enabled ?? null, updatedAt: updatedAtArg }); + } + if (snapshotArg.firmware || this.hasAction(snapshotArg, 'install_firmware')) { + features.push({ id: 'firmware_update', capability: 'sensor', name: 'Firmware Update Available', readable: true, writable: false }); + state.push({ featureId: 'firmware_update', value: snapshotArg.firmware?.available ?? false, updatedAt: updatedAtArg }); + } + + return { + id: this.mainDeviceId(snapshotArg), + integrationDomain: devoloHomeNetworkDomain, + name: this.deviceName(snapshotArg.device), + protocol: 'http', + manufacturer, + model: snapshotArg.device.model || snapshotArg.device.product || 'devolo Home Network device', + online: snapshotArg.connected, + features: this.uniqueFeatures(features), + state, + metadata: this.cleanAttributes({ + host: snapshotArg.device.host || snapshotArg.device.ipAddress, + port: snapshotArg.device.port || devoloHomeNetworkDefaultPort, + serialNumber: snapshotArg.device.serialNumber, + macAddress: snapshotArg.device.macAddress, + mtNumber: snapshotArg.device.mtNumber, + firmwareVersion: snapshotArg.device.firmwareVersion, + configurationUrl: snapshotArg.device.configurationUrl, + features: snapshotArg.device.features, + deviceApi: snapshotArg.device.deviceApi, + plcnetApi: snapshotArg.device.plcnetApi, + liveHttpImplemented: false, + }), + }; + } + + private static plcPeerDevice(snapshotArg: IDevoloSnapshot, deviceArg: IDevoloPlcDevice, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const link = this.linkForPlcDevice(snapshotArg, deviceArg); + const attached = this.plcAttachedToRouter(deviceArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connected_to_router', capability: 'sensor', name: 'Connected to Router', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connected_to_router', value: attached ?? null, updatedAt: updatedAtArg }, + ]; + if (link) { + this.addFeatureState(features, state, 'plc_rx_rate', 'PLC downlink PHY rate', this.linkRxRate(link), updatedAtArg, 'Mbit/s'); + this.addFeatureState(features, state, 'plc_tx_rate', 'PLC uplink PHY rate', this.linkTxRate(link), updatedAtArg, 'Mbit/s'); + } + + return { + id: this.plcDeviceId(deviceArg), + integrationDomain: devoloHomeNetworkDomain, + name: this.plcDeviceName(deviceArg), + protocol: 'unknown', + manufacturer, + model: deviceArg.model || deviceArg.product || 'devolo PLC device', + online: snapshotArg.connected && attached !== false, + features, + state, + metadata: this.cleanAttributes({ + macAddress: this.plcMac(deviceArg), + topology: deviceArg.topology, + serialNumber: deviceArg.serialNumber, + firmwareVersion: deviceArg.firmwareVersion, + attachedToRouter: attached, + link: link ? this.cleanAttributes({ fromMac: this.linkFromMac(link), toMac: this.linkToMac(link), rxRate: this.linkRxRate(link), txRate: this.linkTxRate(link) }) : undefined, + }), + }; + } + + private static stationDevice(snapshotArg: IDevoloSnapshot, stationArg: IDevoloWifiStation, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const connected = stationArg.connected !== false && snapshotArg.connected; + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'ip_address', capability: 'sensor', name: 'IP Address', readable: true, writable: false }, + { id: 'wifi_band', capability: 'sensor', name: 'Wi-Fi Band', readable: true, writable: false }, + { id: 'access_point_type', capability: 'sensor', name: 'Access Point Type', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'presence', value: connected, updatedAt: updatedAtArg }, + { featureId: 'ip_address', value: stationArg.ipAddress || stationArg.ip || null, updatedAt: updatedAtArg }, + { featureId: 'wifi_band', value: this.wifiBand(stationArg.band), updatedAt: updatedAtArg }, + { featureId: 'access_point_type', value: this.vapType(stationArg.vapType ?? stationArg.vap_type), updatedAt: updatedAtArg }, + ]; + const signal = this.numberValue(stationArg.rssi, stationArg.signal); + if (signal !== undefined) { + features.push({ id: 'rssi', capability: 'sensor', name: 'RSSI', readable: true, writable: false, unit: 'dBm' }); + state.push({ featureId: 'rssi', value: signal, updatedAt: updatedAtArg }); + } + + return { + id: this.stationDeviceId(stationArg), + integrationDomain: devoloHomeNetworkDomain, + name: this.stationName(stationArg), + protocol: 'unknown', + manufacturer: 'Unknown', + model: 'Wi-Fi station', + online: connected, + features, + state, + metadata: this.cleanAttributes({ + macAddress: this.stationMac(stationArg), + ipAddress: stationArg.ipAddress || stationArg.ip, + ssid: stationArg.ssid, + vapType: this.vapType(stationArg.vapType ?? stationArg.vap_type), + band: this.wifiBand(stationArg.band), + rssi: signal, + }), + }; + } + + private static pushCoreSensors(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + const deviceId = this.mainDeviceId(snapshotArg); + const name = this.deviceName(snapshotArg.device); + const sensors: Array<[keyof IDevoloSensorMap, string, string | undefined, Record]> = [ + ['connected_plc_devices', 'Connected PLC devices', undefined, { entityCategory: 'diagnostic', entityRegistryEnabledDefault: false }], + ['connected_wifi_clients', 'Connected Wi-Fi clients', undefined, { stateClass: 'measurement' }], + ['neighboring_wifi_networks', 'Neighboring Wi-Fi networks', undefined, { entityCategory: 'diagnostic', entityRegistryEnabledDefault: false }], + ['last_restart', 'Last restart', undefined, { deviceClass: 'timestamp', entityCategory: 'diagnostic', entityRegistryEnabledDefault: false }], + ]; + for (const [key, label, unit, attrs] of sensors) { + const value = snapshotArg.sensors[key]; + if (value === undefined) { + continue; + } + entitiesArg.push(this.entity('sensor', `${name} ${label}`, deviceId, `${this.uniqueBase(snapshotArg)}_${this.slug(String(key))}`, value, usedIdsArg, { + nativeKey: key, + unit, + ...attrs, + }, snapshotArg.connected, `sensor.${this.slug(name)}_${this.slug(String(key))}`)); + } + } + + private static pushSwitchEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + const deviceId = this.mainDeviceId(snapshotArg); + const name = this.deviceName(snapshotArg.device); + if (snapshotArg.switches.leds || this.hasAction(snapshotArg, 'set_leds')) { + entitiesArg.push(this.entity('switch', `${name} Enable LEDs`, deviceId, `${this.uniqueBase(snapshotArg)}_switch_leds`, snapshotArg.switches.leds?.enabled ? 'on' : 'off', usedIdsArg, { + nativeKey: 'switch_leds', + nativeAction: 'set_leds', + writable: true, + entityCategory: 'config', + }, snapshotArg.connected && snapshotArg.switches.leds?.available !== false)); + } + if (snapshotArg.switches.guestWifi || this.hasAction(snapshotArg, 'set_guest_wifi')) { + entitiesArg.push(this.entity('switch', `${name} Enable guest Wi-Fi`, deviceId, `${this.uniqueBase(snapshotArg)}_switch_guest_wifi`, snapshotArg.switches.guestWifi?.enabled ? 'on' : 'off', usedIdsArg, { + nativeKey: 'switch_guest_wifi', + nativeAction: 'set_guest_wifi', + writable: true, + ssid: snapshotArg.switches.guestWifi?.ssid, + keyConfigured: Boolean(snapshotArg.switches.guestWifi?.key || snapshotArg.switches.guestWifi?.password), + }, snapshotArg.connected && snapshotArg.switches.guestWifi?.available !== false)); + } + } + + private static pushButtonEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + const deviceId = this.mainDeviceId(snapshotArg); + const name = this.deviceName(snapshotArg.device); + for (const action of snapshotArg.actions) { + if (action.platform !== 'button' || !['identify', 'pairing', 'restart', 'start_wps'].includes(action.action)) { + continue; + } + entitiesArg.push(this.entity('button', `${name} ${actionLabels[action.action]}`, deviceId, `${this.uniqueBase(snapshotArg)}_${action.action}`, null, usedIdsArg, { + nativeAction: action.action, + commandType: action.type, + target: action.target, + entityCategory: action.action === 'identify' ? 'diagnostic' : action.action === 'restart' ? 'config' : undefined, + requiresPassword: action.requiresPassword, + }, snapshotArg.connected && action.available !== false)); + } + } + + private static pushFirmwareEntity(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + if (!snapshotArg.firmware && !this.hasAction(snapshotArg, 'install_firmware')) { + return; + } + const firmware = snapshotArg.firmware || {}; + const name = this.deviceName(snapshotArg.device); + entitiesArg.push(this.entity('update', `${name} Regular firmware`, this.mainDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_regular_firmware`, firmware.available ? 'on' : 'off', usedIdsArg, { + nativeKey: 'regular_firmware', + nativeAction: 'install_firmware', + deviceClass: 'firmware', + entityCategory: 'config', + installedVersion: firmware.installedVersion || firmware.currentVersion || snapshotArg.device.firmwareVersion, + latestVersion: firmware.latestVersion || firmware.newFirmwareVersion || firmware.installedVersion || snapshotArg.device.firmwareVersion, + inProgress: firmware.inProgress === true, + releaseUrl: firmware.releaseUrl, + supportedFeatures: ['install', 'progress'], + }, snapshotArg.connected)); + } + + private static pushPlcLinkEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + const deviceName = this.deviceName(snapshotArg.device); + const localMac = snapshotArg.device.macAddress; + for (const link of snapshotArg.plcLinks) { + const peerMac = this.linkPeerMac(link, localMac) || this.linkToMac(link); + const peer = snapshotArg.plcDevices.find((deviceArg) => this.plcMac(deviceArg) === peerMac); + const peerName = peer ? this.plcDeviceName(peer) : peerMac || 'PLC peer'; + const suffix = this.slug(peerName || peerMac); + const attrs = { + nativeKey: 'plc_rate', + entityCategory: 'diagnostic', + deviceClass: 'data_rate', + unit: 'Mbit/s', + peerMac, + localMac, + }; + const rx = this.linkRxRate(link); + if (rx !== undefined) { + entitiesArg.push(this.entity('sensor', `${deviceName} PLC downlink PHY rate (${peerName})`, this.mainDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_plc_rx_rate_${suffix}`, rx, usedIdsArg, attrs, snapshotArg.connected)); + } + const tx = this.linkTxRate(link); + if (tx !== undefined) { + entitiesArg.push(this.entity('sensor', `${deviceName} PLC uplink PHY rate (${peerName})`, this.mainDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_plc_tx_rate_${suffix}`, tx, usedIdsArg, attrs, snapshotArg.connected)); + } + } + } + + private static pushStationEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IDevoloSnapshot, usedIdsArg: Map): void { + for (const station of snapshotArg.wifiStations) { + const name = this.stationName(station); + const deviceId = this.stationDeviceId(station); + const mac = this.stationMac(station); + const connected = station.connected !== false && snapshotArg.connected; + entitiesArg.push(this.entity('binary_sensor', `${name} Connected`, deviceId, `devolo_station_${this.slug(mac || name)}_connected`, connected ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + macAddress: mac, + ipAddress: station.ipAddress || station.ip, + ssid: station.ssid, + }, snapshotArg.connected)); + if (station.ipAddress || station.ip) { + entitiesArg.push(this.entity('sensor', `${name} IP address`, deviceId, `devolo_station_${this.slug(mac || name)}_ip`, station.ipAddress || station.ip, usedIdsArg, { nativeKey: 'ip_address', macAddress: mac }, connected)); + } + entitiesArg.push(this.entity('sensor', `${name} Wi-Fi band`, deviceId, `devolo_station_${this.slug(mac || name)}_band`, this.wifiBand(station.band), usedIdsArg, { nativeKey: 'band', macAddress: mac, unit: this.wifiBand(station.band) === 'unknown' ? undefined : 'GHz' }, connected)); + entitiesArg.push(this.entity('sensor', `${name} access point type`, deviceId, `devolo_station_${this.slug(mac || name)}_vap`, this.vapType(station.vapType ?? station.vap_type), usedIdsArg, { nativeKey: 'vap_type', macAddress: mac }, connected)); + const rssi = this.numberValue(station.rssi, station.signal); + if (rssi !== undefined) { + entitiesArg.push(this.entity('sensor', `${name} RSSI`, deviceId, `devolo_station_${this.slug(mac || name)}_rssi`, rssi, usedIdsArg, { nativeKey: 'rssi', macAddress: mac, unit: 'dBm' }, connected)); + } + } + } + + private static command(snapshotArg: IDevoloSnapshot, requestArg: IServiceCallRequest, actionArg: TDevoloActionName, entityArg?: IIntegrationEntity, enabledArg?: boolean, payloadArg: Record = {}): IDevoloCommand | undefined { + const action = snapshotArg.actions.find((actionEntryArg) => actionEntryArg.action === actionArg); + if (!action || action.available === false) { + return undefined; + } + return { + type: action.type, + action: action.action, + service: requestArg.service, + target: requestArg.target, + deviceId: entityArg?.deviceId || action.deviceId || requestArg.target.deviceId || this.mainDeviceId(snapshotArg), + entityId: entityArg?.id || action.entityId || requestArg.target.entityId, + enabled: enabledArg, + requiresPassword: action.requiresPassword, + payload: this.cleanAttributes({ ...payloadArg, enabled: enabledArg }) as Record, + }; + } + + private static actionFromDomainService(serviceArg: string): TDevoloActionName | undefined { + if (serviceArg === 'identify') return 'identify'; + if (serviceArg === 'pairing' || serviceArg === 'pair_device' || serviceArg === 'start_pairing') return 'pairing'; + if (serviceArg === 'restart' || serviceArg === 'reboot') return 'restart'; + if (serviceArg === 'start_wps') return 'start_wps'; + if (serviceArg === 'set_leds') return 'set_leds'; + if (serviceArg === 'set_guest_wifi' || serviceArg === 'set_wifi_guest_access') return 'set_guest_wifi'; + if (serviceArg === 'install_firmware' || serviceArg === 'start_firmware_update') return 'install_firmware'; + return undefined; + } + + private static actionValue(valueArg: unknown): TDevoloActionName | undefined { + if (typeof valueArg !== 'string') { + return undefined; + } + return ['identify', 'pairing', 'restart', 'start_wps', 'set_leds', 'set_guest_wifi', 'install_firmware'].includes(valueArg) ? valueArg as TDevoloActionName : undefined; + } + + private static findTargetEntity(snapshotArg: IDevoloSnapshot, 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); + } + return undefined; + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg?: Record, availableArg = true, entityIdArg?: string): IIntegrationEntity { + const baseId = entityIdArg || `${platformArg}.${this.slug(nameArg)}`; + const current = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, current + 1); + const id = current ? `${baseId}_${current + 1}` : baseId; + return { + id, + uniqueId: uniqueIdArg, + integrationDomain: devoloHomeNetworkDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: this.entityState(stateArg, platformArg), + attributes: this.cleanAttributes(attributesArg || {}), + available: availableArg, + }; + } + + private static entityState(valueArg: unknown, platformArg: TEntityPlatform): unknown { + if (platformArg === 'binary_sensor' || platformArg === 'switch') { + const value = this.booleanValue(valueArg); + return value ? 'on' : 'off'; + } + return valueArg ?? null; + } + + private static mainDeviceId(snapshotArg: IDevoloSnapshot): string { + const key = snapshotArg.device.serialNumber || snapshotArg.device.macAddress || snapshotArg.device.id || snapshotArg.device.host || snapshotArg.device.ipAddress || 'device'; + return `${devoloHomeNetworkDomain}.device.${this.slug(key)}`; + } + + private static plcDeviceId(deviceArg: IDevoloPlcDevice): string { + return `${devoloHomeNetworkDomain}.plc.${this.slug(this.plcMac(deviceArg) || deviceArg.id || this.plcDeviceName(deviceArg))}`; + } + + private static stationDeviceId(stationArg: IDevoloWifiStation): string { + return `${devoloHomeNetworkDomain}.station.${this.slug(this.stationMac(stationArg) || stationArg.id || this.stationName(stationArg))}`; + } + + private static uniqueBase(snapshotArg: IDevoloSnapshot): string { + return `${devoloHomeNetworkDomain}_${this.slug(snapshotArg.device.serialNumber || snapshotArg.device.macAddress || snapshotArg.device.id || snapshotArg.device.host || 'device')}`; + } + + private static deviceName(deviceArg: IDevoloDeviceInfo): string { + return deviceArg.name || this.shortHostname(deviceArg.hostname) || deviceArg.product || deviceArg.model || deviceArg.host || 'devolo Home Network'; + } + + private static plcDeviceName(deviceArg: IDevoloPlcDevice): string { + return deviceArg.userDeviceName || deviceArg.user_device_name || deviceArg.name || this.plcMac(deviceArg) || 'PLC device'; + } + + private static stationName(stationArg: IDevoloWifiStation): string { + return stationArg.name || stationArg.hostname || stationArg.ipAddress || stationArg.ip || this.stationMac(stationArg) || 'Wi-Fi station'; + } + + private static plcMac(deviceArg: IDevoloPlcDevice): string | undefined { + return this.normalizeMac(deviceArg.macAddress || deviceArg.mac_address); + } + + private static stationMac(stationArg: IDevoloWifiStation): string | undefined { + return this.normalizeMac(stationArg.macAddress || stationArg.mac_address); + } + + private static linkFromMac(linkArg: IDevoloPlcLink): string | undefined { + return this.normalizeMac(linkArg.fromMac || linkArg.macAddressFrom || linkArg.mac_address_from); + } + + private static linkToMac(linkArg: IDevoloPlcLink): string | undefined { + return this.normalizeMac(linkArg.toMac || linkArg.macAddressTo || linkArg.mac_address_to); + } + + private static linkRxRate(linkArg: IDevoloPlcLink): number | undefined { + return this.numberValue(linkArg.rxRate, linkArg.rx_rate); + } + + private static linkTxRate(linkArg: IDevoloPlcLink): number | undefined { + return this.numberValue(linkArg.txRate, linkArg.tx_rate); + } + + private static linkPeerMac(linkArg: IDevoloPlcLink, localMacArg?: string): string | undefined { + const from = this.linkFromMac(linkArg); + const to = this.linkToMac(linkArg); + const local = this.normalizeMac(localMacArg); + if (local && from === local) return to; + if (local && to === local) return from; + return to || from; + } + + private static primaryPlcLink(snapshotArg: Pick): IDevoloPlcLink | undefined { + const localMac = this.normalizeMac(snapshotArg.device.macAddress); + return snapshotArg.plcLinks.find((linkArg) => !localMac || this.linkFromMac(linkArg) === localMac || this.linkToMac(linkArg) === localMac); + } + + private static linkForPlcDevice(snapshotArg: IDevoloSnapshot, deviceArg: IDevoloPlcDevice): IDevoloPlcLink | undefined { + const mac = this.plcMac(deviceArg); + return snapshotArg.plcLinks.find((linkArg) => this.linkFromMac(linkArg) === mac || this.linkToMac(linkArg) === mac); + } + + private static connectedToRouter(snapshotArg: IDevoloSnapshot): boolean | undefined { + const localMac = this.normalizeMac(snapshotArg.device.macAddress); + if (!localMac) { + return undefined; + } + const localDevice = snapshotArg.plcDevices.find((deviceArg) => this.plcMac(deviceArg) === localMac); + return localDevice ? this.plcAttachedToRouter(localDevice) : undefined; + } + + private static plcAttachedToRouter(deviceArg: IDevoloPlcDevice): boolean | undefined { + return this.booleanValue(deviceArg.attachedToRouter ?? deviceArg.attached_to_router); + } + + private static isLocalPlcDevice(snapshotArg: IDevoloSnapshot, deviceArg: IDevoloPlcDevice): boolean { + const mac = this.plcMac(deviceArg); + return this.isLocalPlcByMac(deviceArg, snapshotArg.device.macAddress) || Boolean(mac && mac === this.normalizeMac(snapshotArg.device.macAddress)); + } + + private static isLocalPlcByMac(deviceArg: IDevoloPlcDevice, localMacArg?: string): boolean { + const topology = String(deviceArg.topology || '').toLowerCase(); + const mac = this.plcMac(deviceArg); + const localMac = this.normalizeMac(localMacArg); + return topology === 'local' || Boolean(mac && localMac && mac === localMac); + } + + private static hasFeature(deviceArg: IDevoloDeviceInfo, featureArg: string): boolean { + return (deviceArg.features || []).some((entryArg) => String(entryArg).toLowerCase() === featureArg.toLowerCase()); + } + + private static hasAction(snapshotArg: IDevoloSnapshot, actionArg: TDevoloActionName): boolean { + return snapshotArg.actions.some((actionEntryArg) => actionEntryArg.action === actionArg && actionEntryArg.available !== false); + } + + private static wifiBand(valueArg: unknown): string { + if (valueArg === 0 || valueArg === '0' || valueArg === '2' || valueArg === '2g' || valueArg === '2.4' || valueArg === 2.4) { + return '2.4'; + } + if (valueArg === 1 || valueArg === '1' || valueArg === '5' || valueArg === '5g' || valueArg === 5) { + return '5'; + } + return this.stringValue(valueArg) || 'unknown'; + } + + private static vapType(valueArg: unknown): string { + if (valueArg === 0 || valueArg === '0' || String(valueArg).toLowerCase() === 'main') { + return 'Main'; + } + if (valueArg === 1 || valueArg === '1' || String(valueArg).toLowerCase() === 'guest') { + return 'Guest'; + } + return this.stringValue(valueArg) || 'unknown'; + } + + private static uniquePlcDevices(devicesArg: IDevoloPlcDevice[]): IDevoloPlcDevice[] { + const seen = new Set(); + return devicesArg.filter((deviceArg) => { + const key = this.plcMac(deviceArg) || deviceArg.id || this.plcDeviceName(deviceArg); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + private static uniquePlcLinks(linksArg: IDevoloPlcLink[]): IDevoloPlcLink[] { + const seen = new Set(); + return linksArg.filter((linkArg) => { + const key = linkArg.id || `${this.linkFromMac(linkArg) || 'from'}>${this.linkToMac(linkArg) || 'to'}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + private static uniqueWifiStations(stationsArg: IDevoloWifiStation[]): IDevoloWifiStation[] { + const seen = new Set(); + return stationsArg.filter((stationArg) => { + const key = this.stationMac(stationArg) || stationArg.id || this.stationName(stationArg); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + private static uniqueNeighborNetworks(networksArg: IDevoloNeighborWifiNetwork[]): IDevoloNeighborWifiNetwork[] { + const seen = new Set(); + return networksArg.filter((networkArg) => { + const key = networkArg.id || this.normalizeMac(networkArg.bssid || networkArg.macAddress) || `${networkArg.ssid || 'ssid'}:${networkArg.channel || ''}:${networkArg.band || ''}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + private static uniqueActions(actionsArg: IDevoloActionDescriptor[]): IDevoloActionDescriptor[] { + const seen = new Set(); + return actionsArg.filter((actionArg) => { + const key = `${actionArg.action}:${actionArg.entityId || ''}:${actionArg.deviceId || ''}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + private static uniqueDeviceDefinitions(devicesArg: plugins.shxInterfaces.data.IDeviceDefinition[]): plugins.shxInterfaces.data.IDeviceDefinition[] { + const seen = new Set(); + return devicesArg.filter((deviceArg) => { + if (seen.has(deviceArg.id)) return false; + seen.add(deviceArg.id); + return true; + }); + } + + private static uniqueFeatures(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[]): plugins.shxInterfaces.data.IDeviceFeature[] { + const seen = new Set(); + return featuresArg.filter((featureArg) => { + if (seen.has(featureArg.id)) return false; + seen.add(featureArg.id); + return true; + }); + } + + private static uniqueStrings(valuesArg: Array): string[] { + return [...new Set(valuesArg.filter((valueArg): valueArg is string => Boolean(valueArg)))]; + } + + private static addFeatureState(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, updatedAtArg: string, unitArg?: string): void { + if (valueArg === undefined) { + return; + } + featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg }); + stateArg.push({ featureId: idArg, 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) { + return valueArg; + } + if (this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') return valueArg; + if (typeof valueArg === 'number') return valueArg !== 0; + if (typeof valueArg === 'string') { + const value = valueArg.trim().toLowerCase(); + if (['true', 'on', 'yes', '1', 'enabled', 'enable'].includes(value)) return true; + if (['false', 'off', 'no', '0', 'disabled', 'disable'].includes(value)) return false; + } + return undefined; + } + + private static numberValue(...valuesArg: unknown[]): number | undefined { + for (const value of valuesArg) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static shortHostname(valueArg?: string): string | undefined { + return valueArg?.split('.')[0]; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static cleanAttributes(valueArg: TValue): TValue { + if (!this.isRecord(valueArg)) { + return valueArg; + } + return Object.fromEntries(Object.entries(valueArg).filter(([, value]) => value !== undefined)) as TValue; + } +} + +export const DevoloMapper = DevoloHomeNetworkMapper; diff --git a/ts/integrations/devolo_home_network/devolo_home_network.types.ts b/ts/integrations/devolo_home_network/devolo_home_network.types.ts index 3aa36ff..3bf4091 100644 --- a/ts/integrations/devolo_home_network/devolo_home_network.types.ts +++ b/ts/integrations/devolo_home_network/devolo_home_network.types.ts @@ -1,4 +1,326 @@ -export interface IHomeAssistantDevoloHomeNetworkConfig { - // TODO: replace with the TypeScript-native config for devolo_home_network. +import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js'; + +export const devoloHomeNetworkDomain = 'devolo_home_network'; +export const devoloHomeNetworkDefaultPort = 80; +export const devoloHomeNetworkMdnsType = '_dvl-deviceapi._tcp.local.'; + +export type TDevoloSnapshotSource = 'manual' | 'snapshot' | 'provider' | 'runtime'; +export type TDevoloFeature = 'led' | 'restart' | 'update' | 'wifi1' | string; +export type TDevoloActionName = + | 'identify' + | 'pairing' + | 'restart' + | 'start_wps' + | 'set_leds' + | 'set_guest_wifi' + | 'install_firmware'; +export type TDevoloCommandType = + | 'plc.identify' + | 'plc.pair_device' + | 'device.restart' + | 'wifi.start_wps' + | 'device.set_leds' + | 'wifi.set_guest_access' + | 'firmware.install'; + +export interface IDevoloDeviceInfo { + id?: string; + host?: string; + ipAddress?: string; + port?: number; + name?: string; + hostname?: string; + product?: string; + model?: string; + mtNumber?: string; + serialNumber?: string; + macAddress?: string; + firmwareVersion?: string; + features?: TDevoloFeature[]; + deviceApi?: boolean; + plcnetApi?: boolean; + configurationUrl?: string; + metadata?: Record; +} + +export interface IDevoloPlcDevice { + id?: string; + macAddress?: string; + mac_address?: string; + name?: string; + userDeviceName?: string; + user_device_name?: string; + product?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + topology?: 'LOCAL' | 'REMOTE' | 'local' | 'remote' | string; + attachedToRouter?: boolean; + attached_to_router?: boolean; + ipAddress?: string; + metadata?: Record; +} + +export interface IDevoloPlcLink { + id?: string; + fromMac?: string; + toMac?: string; + macAddressFrom?: string; + macAddressTo?: string; + mac_address_from?: string; + mac_address_to?: string; + rxRate?: number; + txRate?: number; + rx_rate?: number; + tx_rate?: number; + quality?: string | number; + metadata?: Record; +} + +export interface IDevoloWifiStation { + id?: string; + macAddress?: string; + mac_address?: string; + name?: string; + hostname?: string; + ipAddress?: string; + ip?: string; + connected?: boolean; + vapType?: string | number; + vap_type?: string | number; + band?: string | number; + ssid?: string; + rssi?: number; + signal?: number; + metadata?: Record; +} + +export interface IDevoloNeighborWifiNetwork { + id?: string; + ssid?: string; + bssid?: string; + macAddress?: string; + band?: string | number; + channel?: number; + rssi?: number; + signal?: number; + encryption?: string; + metadata?: Record; +} + +export interface IDevoloGuestWifiState { + enabled?: boolean; + ssid?: string; + key?: string; + password?: string; + band?: string | number; + duration?: number; + available?: boolean; + metadata?: Record; +} + +export interface IDevoloSwitchState { + enabled?: boolean; + available?: boolean; + metadata?: Record; +} + +export interface IDevoloSwitchMap { + switch_leds?: IDevoloSwitchState | boolean; + leds?: IDevoloSwitchState | boolean; + switch_guest_wifi?: IDevoloGuestWifiState | IDevoloSwitchState | boolean; + guestWifi?: IDevoloGuestWifiState | IDevoloSwitchState | boolean; [key: string]: unknown; } + +export interface IDevoloFirmwareUpdate { + installedVersion?: string; + currentVersion?: string; + latestVersion?: string; + newFirmwareVersion?: string; + available?: boolean; + inProgress?: boolean; + releaseUrl?: string; + metadata?: Record; +} + +export interface IDevoloSensorMap { + connected_plc_devices?: number; + connected_wifi_clients?: number; + neighboring_wifi_networks?: number; + plc_rx_rate?: number; + plc_tx_rate?: number; + last_restart?: string | number; + uptimeSeconds?: number; + [key: string]: unknown; +} + +export interface IDevoloActionDescriptor { + action: TDevoloActionName; + type: TDevoloCommandType; + target: 'device' | 'plcnet' | 'wifi' | 'firmware'; + platform?: TEntityPlatform; + entityId?: string; + deviceId?: string; + available?: boolean; + requiresPassword?: boolean; + metadata?: Record; +} + +export interface IDevoloEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + action?: TDevoloActionName; + command?: IDevoloCommand; + data?: unknown; + error?: string; + [key: string]: unknown; +} + +export interface IDevoloSnapshot { + connected: boolean; + source?: TDevoloSnapshotSource; + updatedAt?: string; + device: IDevoloDeviceInfo; + plcDevices: IDevoloPlcDevice[]; + plcLinks: IDevoloPlcLink[]; + wifiStations: IDevoloWifiStation[]; + neighboringWifiNetworks: IDevoloNeighborWifiNetwork[]; + firmware?: IDevoloFirmwareUpdate; + sensors: IDevoloSensorMap; + switches: { + leds?: IDevoloSwitchState; + guestWifi?: IDevoloGuestWifiState; + }; + actions: IDevoloActionDescriptor[]; + events: IDevoloEvent[]; + error?: string; + metadata?: Record; +} + +export interface IDevoloManualEntry { + id?: string; + host?: string; + ipAddress?: string; + port?: number; + name?: string; + hostname?: string; + product?: string; + model?: string; + mtNumber?: string; + serialNumber?: string; + macAddress?: string; + firmwareVersion?: string; + features?: TDevoloFeature[]; + device?: IDevoloDeviceInfo; + snapshot?: IDevoloSnapshot; + plcDevices?: IDevoloPlcDevice[]; + devices?: IDevoloPlcDevice[]; + plcLinks?: IDevoloPlcLink[]; + links?: IDevoloPlcLink[]; + dataRates?: IDevoloPlcLink[]; + wifiStations?: IDevoloWifiStation[]; + stations?: IDevoloWifiStation[]; + clients?: IDevoloWifiStation[]; + neighboringWifiNetworks?: IDevoloNeighborWifiNetwork[]; + neighbors?: IDevoloNeighborWifiNetwork[]; + firmware?: IDevoloFirmwareUpdate; + sensors?: IDevoloSensorMap; + switches?: IDevoloSwitchMap; + actions?: IDevoloActionDescriptor[]; + metadata?: Record; + integrationDomain?: string; + [key: string]: unknown; +} + +export interface IDevoloConfig { + host?: string; + ipAddress?: string; + port?: number; + password?: string; + name?: string; + hostname?: string; + product?: string; + model?: string; + mtNumber?: string; + serialNumber?: string; + macAddress?: string; + firmwareVersion?: string; + features?: TDevoloFeature[]; + connected?: boolean; + deviceApi?: boolean; + plcnetApi?: boolean; + snapshot?: IDevoloSnapshot; + snapshotProvider?: () => Promise | IDevoloSnapshot | undefined; + device?: IDevoloDeviceInfo; + manualEntries?: IDevoloManualEntry[]; + plcDevices?: IDevoloPlcDevice[]; + devices?: IDevoloPlcDevice[]; + plcLinks?: IDevoloPlcLink[]; + links?: IDevoloPlcLink[]; + dataRates?: IDevoloPlcLink[]; + wifiStations?: IDevoloWifiStation[]; + stations?: IDevoloWifiStation[]; + clients?: IDevoloWifiStation[]; + neighboringWifiNetworks?: IDevoloNeighborWifiNetwork[]; + neighbors?: IDevoloNeighborWifiNetwork[]; + firmware?: IDevoloFirmwareUpdate; + sensors?: IDevoloSensorMap; + switches?: IDevoloSwitchMap; + events?: IDevoloEvent[]; + actions?: IDevoloActionDescriptor[]; + commandExecutor?: TDevoloCommandExecutor; + nativeClient?: IDevoloNativeClient; + metadata?: Record; + [key: string]: unknown; +} + +export interface IDevoloCommand { + type: TDevoloCommandType; + action: TDevoloActionName; + service: string; + target: { + entityId?: string; + deviceId?: string; + }; + deviceId?: string; + entityId?: string; + enabled?: boolean; + requiresPassword?: boolean; + payload: Record; +} + +export interface IDevoloCommandResult extends IServiceCallResult {} + +export type TDevoloCommandExecutor = ( + commandArg: IDevoloCommand +) => Promise | IDevoloCommandResult | unknown; + +export interface IDevoloNativeClient { + getSnapshot?: () => Promise | IDevoloSnapshot; + executeCommand?: TDevoloCommandExecutor; + validate?: (configArg: IDevoloConfig) => Promise | IDevoloDeviceInfo | undefined; + destroy?: () => Promise | void; +} + +export interface IDevoloMdnsRecord { + type?: string; + serviceType?: string; + name?: string; + hostname?: string; + host?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; + [key: string]: unknown; +} + +export interface IDevoloManualDiscoveryRecord extends IDevoloManualEntry { + manufacturer?: string; + brand?: string; +} + +export interface IHomeAssistantDevoloHomeNetworkConfig extends IDevoloConfig {} diff --git a/ts/integrations/devolo_home_network/index.ts b/ts/integrations/devolo_home_network/index.ts index 2b2f7a2..504057b 100644 --- a/ts/integrations/devolo_home_network/index.ts +++ b/ts/integrations/devolo_home_network/index.ts @@ -1,2 +1,6 @@ +export * from './devolo_home_network.classes.client.js'; +export * from './devolo_home_network.classes.configflow.js'; export * from './devolo_home_network.classes.integration.js'; +export * from './devolo_home_network.discovery.js'; +export * from './devolo_home_network.mapper.js'; export * from './devolo_home_network.types.js'; diff --git a/ts/integrations/fritz/.generated-by-smarthome-exchange b/ts/integrations/fritz/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/fritz/.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/fritz/fritz.classes.client.ts b/ts/integrations/fritz/fritz.classes.client.ts new file mode 100644 index 0000000..9da558d --- /dev/null +++ b/ts/integrations/fritz/fritz.classes.client.ts @@ -0,0 +1,112 @@ +import type { IFritzCommand, IFritzCommandResult, IFritzConfig, IFritzEvent, IFritzSnapshot } from './fritz.types.js'; +import { FritzMapper } from './fritz.mapper.js'; + +type TFritzEventHandler = (eventArg: IFritzEvent) => void; + +export class FritzClient { + private currentSnapshot?: IFritzSnapshot; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IFritzConfig) {} + + public async getSnapshot(): Promise { + if (this.config.nativeClient) { + this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider'); + return this.cloneSnapshot(this.currentSnapshot); + } + + if (this.config.snapshotProvider) { + const provided = await this.config.snapshotProvider(); + if (provided) { + this.currentSnapshot = this.normalizeSnapshot(provided, 'provider'); + return this.cloneSnapshot(this.currentSnapshot); + } + } + + if (!this.currentSnapshot) { + this.currentSnapshot = FritzMapper.toSnapshot(this.config); + } + return this.cloneSnapshot(this.currentSnapshot); + } + + public onEvent(handlerArg: TFritzEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async refresh(): Promise { + try { + this.currentSnapshot = undefined; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'snapshot_refreshed', snapshot, timestamp: Date.now() }); + return { success: true, data: snapshot }; + } catch (errorArg) { + const error = errorArg instanceof Error ? errorArg.message : String(errorArg); + const snapshot = FritzMapper.toSnapshot({ ...this.config, connected: false, snapshot: this.currentSnapshot }, false); + this.currentSnapshot = snapshot; + this.emit({ type: 'refresh_failed', snapshot, error, timestamp: Date.now() }); + return { success: false, error, data: snapshot }; + } + } + + public async sendCommand(commandArg: IFritzCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + + const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient); + if (!executor) { + const result: IFritzCommandResult = { + success: false, + error: this.unsupportedCommandMessage(commandArg), + data: { command: commandArg }, + }; + this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } + + try { + const result = this.commandResult(await executor(commandArg), commandArg); + this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } catch (errorArg) { + const result: IFritzCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } }; + this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + return result; + } + } + + public async destroy(): Promise { + await this.config.nativeClient?.destroy?.(); + this.eventHandlers.clear(); + } + + private normalizeSnapshot(snapshotArg: IFritzSnapshot, sourceArg: IFritzSnapshot['source']): IFritzSnapshot { + const normalized = FritzMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected); + return { ...normalized, source: snapshotArg.source || sourceArg }; + } + + private commandResult(resultArg: unknown, commandArg: IFritzCommand): IFritzCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IFritzCommandResult { + return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg); + } + + private unsupportedCommandMessage(commandArg: IFritzCommand): string { + const action = commandArg.action.replace(/_/g, ' '); + return `FRITZ!Box live TR-064/HTTP ${action} commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live router, switch, client, or service actions.`; + } + + private emit(eventArg: IFritzEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private cloneSnapshot(snapshotArg: T): T { + return JSON.parse(JSON.stringify(snapshotArg)) as T; + } +} diff --git a/ts/integrations/fritz/fritz.classes.configflow.ts b/ts/integrations/fritz/fritz.classes.configflow.ts new file mode 100644 index 0000000..a43cc50 --- /dev/null +++ b/ts/integrations/fritz/fritz.classes.configflow.ts @@ -0,0 +1,120 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { FritzMapper } from './fritz.mapper.js'; +import type { IFritzConfig, IFritzSnapshot } from './fritz.types.js'; +import { fritzDefaultConsiderHomeSeconds, fritzDefaultHost, fritzDefaultSsl } from './fritz.types.js'; + +export class FritzConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const metadata = candidateArg.metadata || {}; + const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl; + return { + kind: 'form', + title: 'Connect FRITZ!Box Tools', + description: 'Provide the local FRITZ!Box endpoint. Snapshot/manual data is supported directly; live TR-064/HTTP success is not assumed without an injected native client or command executor.', + fields: [ + { name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : `Host (${fritzDefaultHost})`, type: 'text', required: true }, + { name: 'port', label: `Port (${candidateArg.port || FritzMapper.defaultPort(ssl)})`, type: 'number' }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + { name: 'ssl', label: 'Use SSL', type: 'boolean' }, + { name: 'featureDeviceTracking', label: 'Enable network device tracking', type: 'boolean' }, + { name: 'oldDiscovery', label: 'Use old hosts discovery method', type: 'boolean' }, + { name: 'considerHomeSeconds', label: `Seconds to consider a device home (${fritzDefaultConsiderHomeSeconds})`, type: 'number' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => this.submit(candidateArg, valuesArg), + }; + } + + private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record): Promise> { + const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot); + if (snapshot instanceof Error) { + return { kind: 'error', title: 'Invalid FRITZ!Box snapshot', error: snapshot.message }; + } + + const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(candidateArg.metadata?.ssl) ?? snapshot?.router.ssl ?? fritzDefaultSsl; + const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host || (!snapshot ? fritzDefaultHost : undefined); + if (!host && !snapshot) { + return { kind: 'error', title: 'FRITZ!Box setup failed', error: 'FRITZ!Box setup requires a host or snapshot JSON.' }; + } + + const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? FritzMapper.defaultPort(ssl) : undefined); + const config: IFritzConfig = { + host, + port, + ssl, + username: this.stringValue(valuesArg.username), + password: this.stringValue(valuesArg.password), + featureDeviceTracking: this.booleanValue(valuesArg.featureDeviceTracking) ?? true, + oldDiscovery: this.booleanValue(valuesArg.oldDiscovery) ?? false, + considerHomeSeconds: this.numberValue(valuesArg.considerHomeSeconds) || fritzDefaultConsiderHomeSeconds, + uniqueId: candidateArg.id || snapshot?.router.serialNumber || snapshot?.router.macAddress, + name: candidateArg.name || snapshot?.router.name || snapshot?.router.model || host, + model: candidateArg.model || snapshot?.router.model, + snapshot, + metadata: { + discoverySource: candidateArg.source, + discoveryMetadata: candidateArg.metadata, + upstreamSupportsSsdp: true, + upstreamSupportsZeroconf: false, + liveTr064Implemented: false, + }, + }; + + return { + kind: 'done', + title: 'FRITZ!Box Tools configured', + config, + }; + } + + private snapshotFromInput(valueArg: unknown): IFritzSnapshot | undefined | Error { + if (valueArg && typeof valueArg === 'object') { + return valueArg as IFritzSnapshot; + } + const text = this.stringValue(valueArg); + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as IFritzSnapshot; + if (!parsed || typeof parsed !== 'object' || !parsed.router) { + return new Error('Snapshot JSON must include a router object.'); + } + return parsed; + } catch (errorArg) { + return errorArg instanceof Error ? errorArg : new Error(String(errorArg)); + } + } + + 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) && valueArg >= 0) { + return Math.round(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined; + } + return undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) { + return true; + } + if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) { + return false; + } + } + return undefined; + } +} diff --git a/ts/integrations/fritz/fritz.classes.integration.ts b/ts/integrations/fritz/fritz.classes.integration.ts index 7a674f8..5c718b8 100644 --- a/ts/integrations/fritz/fritz.classes.integration.ts +++ b/ts/integrations/fritz/fritz.classes.integration.ts @@ -1,32 +1,98 @@ -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 { FritzClient } from './fritz.classes.client.js'; +import { FritzConfigFlow } from './fritz.classes.configflow.js'; +import { createFritzDiscoveryDescriptor } from './fritz.discovery.js'; +import { FritzMapper } from './fritz.mapper.js'; +import type { IFritzConfig } from './fritz.types.js'; +import { fritzDomain } from './fritz.types.js'; -export class HomeAssistantFritzIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "fritz", - displayName: "FRITZ!Box Tools", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/fritz", - "upstreamDomain": "fritz", - "integrationType": "hub", - "iotClass": "local_polling", - "qualityScale": "gold", - "requirements": [ - "fritzconnection[qr]==1.15.1", - "xmltodict==1.0.4" - ], - "dependencies": [ - "network" - ], - "afterDependencies": [], - "codeowners": [ - "@AaronDavidSchneider", - "@chemelli74", - "@mib1185" - ] -}, - }); +export class FritzIntegration extends BaseIntegration { + public readonly domain = fritzDomain; + public readonly displayName = 'FRITZ!Box Tools'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createFritzDiscoveryDescriptor(); + public readonly configFlow = new FritzConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/fritz', + upstreamDomain: fritzDomain, + integrationType: 'hub', + iotClass: 'local_polling', + qualityScale: 'gold', + requirements: ['fritzconnection[qr]==1.15.1', 'xmltodict==1.0.4'], + dependencies: ['network'], + afterDependencies: [] as string[], + codeowners: ['@AaronDavidSchneider', '@chemelli74', '@mib1185'], + documentation: 'https://www.home-assistant.io/integrations/fritz', + configFlow: true, + runtime: { + mode: 'native TypeScript snapshot/manual FRITZ!Box mapping', + platforms: ['binary_sensor', 'button', 'device_tracker', 'sensor', 'switch', 'update'], + services: ['refresh', 'snapshot', 'reboot', 'reconnect', 'firmware_update', 'cleanup', 'set_guest_wifi_password', 'dial', 'wake_on_lan'], + }, + localApi: { + implemented: [ + 'manual FRITZ!Box setup candidates and config flow', + 'SSDP FRITZ!Box device-type candidates matching Home Assistant manifest support', + 'snapshot mapping for router connection sensors, traffic counters/rates, interfaces, client presence/device-tracker equivalents, Wi-Fi, port forwarding, call deflection, internet-access, WOL, and FRITZ!OS update controls', + 'safe command modeling for explicitly represented router, client, switch, update, and service actions', + ], + explicitUnsupported: [ + 'homeassistant_compat shims', + 'fake FRITZ!Box TR-064/HTTP connection or command success without commandExecutor/nativeClient injection', + 'full fritzconnection live protocol implementation in dependency-free TypeScript', + 'Home Assistant image platform mapping because the current integration entity platform model has no image entity type', + ], + }, + }; + + public async setup(configArg: IFritzConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new FritzRuntime(new FritzClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantFritzIntegration extends FritzIntegration {} + +class FritzRuntime implements IIntegrationRuntime { + public domain = fritzDomain; + + constructor(private readonly client: FritzClient) {} + + public async devices(): Promise { + return FritzMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return FritzMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(FritzMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain === fritzDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) { + return { success: true, data: await this.client.getSnapshot() }; + } + if (requestArg.domain === fritzDomain && requestArg.service === 'refresh') { + return this.client.refresh(); + } + const snapshot = await this.client.getSnapshot(); + const command = FritzMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported FRITZ!Box service mapping: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/fritz/fritz.discovery.ts b/ts/integrations/fritz/fritz.discovery.ts new file mode 100644 index 0000000..902a93c --- /dev/null +++ b/ts/integrations/fritz/fritz.discovery.ts @@ -0,0 +1,297 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { FritzMapper } from './fritz.mapper.js'; +import type { IFritzManualDiscoveryRecord, IFritzMdnsDiscoveryRecord, IFritzSnapshot, IFritzSsdpDiscoveryRecord } from './fritz.types.js'; +import { fritzDefaultSsl, fritzDomain } from './fritz.types.js'; + +const fritzTextHints = ['fritz!box', 'fritzbox', 'fritz box', 'fritz!repeater', 'fritz!wlan', 'avm']; +const fritzSsdpSt = 'urn:schemas-upnp-org:device:fritzbox:1'; + +export class FritzManualMatcher implements IDiscoveryMatcher { + public id = 'fritz-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual FRITZ!Box Tools setup entries, including snapshot-only records.'; + + public async matches(inputArg: IFritzManualDiscoveryRecord): Promise { + const metadata = inputArg.metadata || {}; + const snapshot = inputArg.snapshot || metadata.snapshot as IFritzSnapshot | undefined; + const host = inputArg.host || snapshot?.router.host; + const mac = FritzMapper.normalizeMac(inputArg.macAddress || inputArg.serialNumber || snapshot?.router.macAddress || snapshot?.router.serialNumber); + const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.name); + const hasSnapshot = Boolean(snapshot); + const matched = inputArg.integrationDomain === fritzDomain + || metadata.fritz === true + || hasSnapshot + || fritzTextHints.some((hintArg) => text.includes(hintArg)) + || Boolean(host && !text); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain FRITZ!Box setup hints.' }; + } + + const ssl = this.booleanValue(inputArg.ssl) ?? this.booleanValue(metadata.ssl) ?? snapshot?.router.ssl ?? fritzDefaultSsl; + const port = inputArg.port || snapshot?.router.port || FritzMapper.defaultPort(ssl); + const id = inputArg.id || inputArg.serialNumber || snapshot?.router.serialNumber || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined); + return { + matched: true, + confidence: hasSnapshot || mac ? 'certain' : host ? 'high' : 'medium', + reason: hasSnapshot ? 'Manual entry includes a FRITZ!Box snapshot.' : 'Manual entry can start FRITZ!Box Tools setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: fritzDomain, + id, + host, + port, + name: inputArg.name || snapshot?.router.name || snapshot?.router.model || host || 'FRITZ!Box', + manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'FRITZ!', + model: inputArg.model || snapshot?.router.model || 'FRITZ!Box', + serialNumber: inputArg.serialNumber || snapshot?.router.serialNumber, + macAddress: mac, + metadata: { + ...metadata, + fritz: true, + ssl, + hasSnapshot, + upstreamSupportsSsdp: true, + upstreamSupportsZeroconf: false, + liveTr064Implemented: false, + }, + }, + metadata: { hasSnapshot, ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false }, + }; + } + + private text(...valuesArg: unknown[]): string { + return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } +} + +export class FritzSsdpMatcher implements IDiscoveryMatcher { + public id = 'fritz-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Home Assistant supported FRITZ!Box SSDP advertisements.'; + + public async matches(inputArg: IFritzSsdpDiscoveryRecord): Promise { + const metadata = inputArg.metadata || {}; + const upnp = inputArg.upnp || {}; + const st = this.stringValue(inputArg.st || metadata.st || metadata.ssdpSt); + const location = this.stringValue(inputArg.ssdpLocation || inputArg.ssdp_location || inputArg.location || metadata.ssdpLocation || metadata.location); + const host = inputArg.host || this.hostFromLocation(location); + const friendlyName = this.firstString(upnp.friendlyName, upnp.FriendlyName, upnp.friendly_name, upnp['upnp:ATTR_UPNP_FRIENDLY_NAME'], upnp['friendlyName'], metadata.friendlyName, inputArg.name); + const modelName = this.firstString(upnp.modelName, upnp.ModelName, upnp.model_name, upnp['upnp:ATTR_UPNP_MODEL_NAME'], metadata.modelName, inputArg.model); + const udn = this.firstString(upnp.UDN, upnp.udn, metadata.udn, inputArg.udn, inputArg.usn); + const uuid = udn?.replace(/^uuid:/i, '').split('::')[0]; + const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, friendlyName, modelName, st, metadata.manufacturer, metadata.model, metadata.name); + const matched = inputArg.integrationDomain === fritzDomain + || metadata.fritz === true + || st === fritzSsdpSt + || text.includes(fritzSsdpSt) + || fritzTextHints.some((hintArg) => text.includes(hintArg)); + + if (!matched || !host) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'FRITZ!Box SSDP advertisement lacks a usable host.' : 'SSDP advertisement is not FRITZ!Box Tools.', + }; + } + + const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl; + const port = inputArg.port || this.portFromLocation(location) || FritzMapper.defaultPort(ssl); + return { + matched: true, + confidence: st === fritzSsdpSt || uuid ? 'certain' : 'high', + reason: 'SSDP advertisement matches the FRITZ!Box device type supported by Home Assistant.', + normalizedDeviceId: inputArg.id || uuid || `${host}:${port}`, + candidate: { + source: 'ssdp', + integrationDomain: fritzDomain, + id: inputArg.id || uuid || `${host}:${port}`, + host, + port, + name: inputArg.name || friendlyName || modelName || 'FRITZ!Box', + manufacturer: inputArg.manufacturer || 'FRITZ!', + model: inputArg.model || modelName || 'FRITZ!Box', + serialNumber: uuid, + metadata: { + ...metadata, + fritz: true, + ssl, + ssdpSt: st, + ssdpLocation: location, + upnp, + upstreamSupportsSsdp: true, + upstreamSupportsZeroconf: false, + liveTr064Implemented: false, + }, + }, + metadata: { ssl, ssdpSt: st, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false }, + }; + } + + private firstString(...valuesArg: unknown[]): string | undefined { + return valuesArg.find((valueArg): valueArg is string => typeof valueArg === 'string' && Boolean(valueArg.trim()))?.trim(); + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private text(...valuesArg: unknown[]): string { + return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private hostFromLocation(locationArg?: string): string | undefined { + if (!locationArg) { + return undefined; + } + try { + const host = new URL(locationArg).hostname; + return host && !host.toLowerCase().startsWith('fe80') ? host : undefined; + } catch { + return undefined; + } + } + + private portFromLocation(locationArg?: string): number | undefined { + if (!locationArg) { + return undefined; + } + try { + const parsed = Number(new URL(locationArg).port); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } catch { + return undefined; + } + } +} + +export class FritzMdnsMatcher implements IDiscoveryMatcher { + public id = 'fritz-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize zeroconf/mDNS FRITZ candidates when supplied by a local discovery layer.'; + + public async matches(inputArg: IFritzMdnsDiscoveryRecord): Promise { + const metadata = inputArg.metadata || {}; + const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, inputArg.serviceType, inputArg.type, inputArg.fullname, metadata.manufacturer, metadata.model, metadata.name, metadata.serviceType); + const matched = inputArg.integrationDomain === fritzDomain + || metadata.fritz === true + || fritzTextHints.some((hintArg) => text.includes(hintArg)); + + if (!matched || !inputArg.host) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'FRITZ!Box zeroconf candidate lacks a usable host.' : 'mDNS candidate is not FRITZ!Box Tools.', + }; + } + + const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl; + const id = inputArg.id || inputArg.serialNumber || FritzMapper.normalizeMac(inputArg.macAddress) || `${inputArg.host}:${inputArg.port || FritzMapper.defaultPort(ssl)}`; + return { + matched: true, + confidence: inputArg.serialNumber || inputArg.macAddress ? 'certain' : 'high', + reason: 'mDNS/zeroconf candidate contains FRITZ!Box identity hints.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: fritzDomain, + id, + host: inputArg.host, + port: inputArg.port || FritzMapper.defaultPort(ssl), + name: inputArg.name || inputArg.model || 'FRITZ!Box', + manufacturer: inputArg.manufacturer || 'FRITZ!', + model: inputArg.model || 'FRITZ!Box', + serialNumber: inputArg.serialNumber, + macAddress: FritzMapper.normalizeMac(inputArg.macAddress), + metadata: { + ...metadata, + fritz: true, + ssl, + upstreamSupportsSsdp: true, + upstreamSupportsZeroconf: false, + liveTr064Implemented: false, + }, + }, + metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false }, + }; + } + + private text(...valuesArg: unknown[]): string { + return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } +} + +export class FritzCandidateValidator implements IDiscoveryValidator { + public id = 'fritz-candidate-validator'; + public description = 'Validate FRITZ candidates have a host or snapshot and FRITZ!Box identity metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const snapshot = metadata.snapshot as IFritzSnapshot | undefined; + const mac = FritzMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress || snapshot?.router.serialNumber); + const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.ssdpSt] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const matched = candidateArg.integrationDomain === fritzDomain + || metadata.fritz === true + || Boolean(snapshot) + || metadata.ssdpSt === fritzSsdpSt + || fritzTextHints.some((hintArg) => text.includes(hintArg)); + const hasUsableSource = Boolean(candidateArg.host || snapshot); + + if (!matched || !hasUsableSource) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'FRITZ!Box candidate lacks host or snapshot information.' : 'Candidate is not FRITZ!Box Tools.', + }; + } + + const ssl = typeof metadata.ssl === 'boolean' ? metadata.ssl : snapshot?.router.ssl ?? fritzDefaultSsl; + const port = candidateArg.port || snapshot?.router.port || FritzMapper.defaultPort(ssl); + const normalizedDeviceId = candidateArg.id || snapshot?.router.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id); + return { + matched: true, + confidence: mac || snapshot || metadata.ssdpSt === fritzSsdpSt ? 'certain' : candidateArg.host ? 'high' : 'medium', + reason: 'Candidate has FRITZ!Box metadata and a usable local source.', + normalizedDeviceId, + candidate: { + ...candidateArg, + id: candidateArg.id || normalizedDeviceId, + port, + macAddress: mac || candidateArg.macAddress, + metadata: { + ...metadata, + ssl, + upstreamSupportsSsdp: true, + upstreamSupportsZeroconf: false, + liveTr064Implemented: false, + }, + }, + metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false }, + }; + } +} + +export const createFritzDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: fritzDomain, displayName: 'FRITZ!Box Tools' }) + .addMatcher(new FritzManualMatcher()) + .addMatcher(new FritzSsdpMatcher()) + .addMatcher(new FritzMdnsMatcher()) + .addValidator(new FritzCandidateValidator()); +}; diff --git a/ts/integrations/fritz/fritz.mapper.ts b/ts/integrations/fritz/fritz.mapper.ts new file mode 100644 index 0000000..0c69f1e --- /dev/null +++ b/ts/integrations/fritz/fritz.mapper.ts @@ -0,0 +1,1104 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IFritzActionDescriptor, + IFritzCallDeflection, + IFritzClientDevice, + IFritzCommand, + IFritzConfig, + IFritzConnectionInfo, + IFritzEvent, + IFritzInterfaceStats, + IFritzManualEntry, + IFritzPortForward, + IFritzRouterInfo, + IFritzSensorMap, + IFritzSnapshot, + IFritzUpdateInfo, + IFritzWifiNetwork, + TFritzAction, + TFritzClientAction, + TFritzMeshRole, + TFritzProtocol, + TFritzRouterAction, + TFritzServiceAction, + TFritzSwitchAction, +} from './fritz.types.js'; +import { fritzDefaultHttpPort, fritzDefaultHttpsPort, fritzDefaultSsl, fritzDomain } from './fritz.types.js'; + +type TSensorDescriptor = { + key: keyof IFritzSensorMap & string; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + enabledByDefault?: boolean; + transform?: (valueArg: unknown) => unknown; +}; + +const manufacturer = 'FRITZ!'; + +const connectionSensorDescriptors: TSensorDescriptor[] = [ + { key: 'external_ip', name: 'External IP', deviceClass: 'ip' }, + { key: 'external_ipv6', name: 'External IPv6', deviceClass: 'ip' }, + { key: 'connection_uptime', name: 'Connection uptime', deviceClass: 'timestamp', entityCategory: 'diagnostic', transform: (valueArg) => FritzMapper.uptimeValue(valueArg) }, + { key: 'kb_s_sent', name: 'Upload throughput', unit: 'kB/s', deviceClass: 'data_rate', stateClass: 'measurement' }, + { key: 'kb_s_received', name: 'Download throughput', unit: 'kB/s', deviceClass: 'data_rate', stateClass: 'measurement' }, + { key: 'max_kb_s_sent', name: 'Max connection upload throughput', unit: 'kbit/s', deviceClass: 'data_rate' }, + { key: 'max_kb_s_received', name: 'Max connection download throughput', unit: 'kbit/s', deviceClass: 'data_rate' }, + { key: 'gb_sent', name: 'GB sent', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing' }, + { key: 'gb_received', name: 'GB received', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing' }, + { key: 'link_kb_s_sent', name: 'Link upload throughput', unit: 'kbit/s', deviceClass: 'data_rate', entityCategory: 'diagnostic' }, + { key: 'link_kb_s_received', name: 'Link download throughput', unit: 'kbit/s', deviceClass: 'data_rate', entityCategory: 'diagnostic' }, + { key: 'link_noise_margin_sent', name: 'Link upload noise margin', unit: 'dB', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'link_noise_margin_received', name: 'Link download noise margin', unit: 'dB', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'link_attenuation_sent', name: 'Link upload power attenuation', unit: 'dB', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'link_attenuation_received', name: 'Link download power attenuation', unit: 'dB', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'device_uptime', name: 'Device uptime', deviceClass: 'timestamp', entityCategory: 'diagnostic', transform: (valueArg) => FritzMapper.uptimeValue(valueArg) }, + { key: 'cpu_temperature', name: 'CPU temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, +]; + +export class FritzMapper { + public static toSnapshot(configArg: IFritzConfig, connectedArg?: boolean, eventsArg: IFritzEvent[] = []): IFritzSnapshot { + const source = configArg.snapshot; + const manualSnapshots = (configArg.manualEntries || []) + .map((entryArg) => entryArg.snapshot) + .filter((snapshotArg): snapshotArg is IFritzSnapshot => Boolean(snapshotArg)); + const manualData = this.mergeManualEntries(configArg.manualEntries || []); + const router = this.routerInfo(configArg, source, manualSnapshots, manualData.router); + const devices = this.uniqueClients([ + ...(source?.devices || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.devices || []), + ...(configArg.devices || []), + ...(configArg.clients || []), + ...manualData.devices, + ]); + const interfaces = this.uniqueInterfaces([ + ...(source?.interfaces || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.interfaces || []), + ...(configArg.interfaces || []), + ...manualData.interfaces, + ]); + const connection = this.connectionInfo([ + source?.connection, + ...manualSnapshots.map((snapshotArg) => snapshotArg.connection), + configArg.connection, + manualData.connection, + ], router); + const sensors = this.sensorMap([ + source?.sensors, + ...manualSnapshots.map((snapshotArg) => snapshotArg.sensors), + configArg.sensors, + manualData.sensors, + ], connection, devices, interfaces); + const wifiNetworks = this.uniqueWifiNetworks([ + ...(source?.wifiNetworks || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.wifiNetworks || []), + ...(configArg.wifiNetworks || []), + ...manualData.wifiNetworks, + ]); + const portForwards = this.uniquePortForwards([ + ...(source?.portForwards || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.portForwards || []), + ...(configArg.portForwards || []), + ...manualData.portForwards, + ]); + const callDeflections = this.uniqueCallDeflections([ + ...(source?.callDeflections || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.callDeflections || []), + ...(configArg.callDeflections || []), + ...manualData.callDeflections, + ]); + const update = this.updateInfo(source?.update, ...manualSnapshots.map((snapshotArg) => snapshotArg.update), configArg.update, manualData.update, router); + const actions = this.uniqueActions([ + ...(source?.actions || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.actions || []), + ...(configArg.actions || []), + ...manualData.actions, + ...this.actionsFromRouter(router), + ...this.actionsFromClients(devices), + ...this.actionsFromRepresentedControls(wifiNetworks, portForwards, callDeflections), + ]); + const hasManualData = Boolean( + source + || manualSnapshots.length + || configArg.router + || configArg.devices?.length + || configArg.clients?.length + || configArg.interfaces?.length + || configArg.connection + || configArg.sensors + || configArg.wifiNetworks?.length + || configArg.portForwards?.length + || configArg.callDeflections?.length + || configArg.update + || manualData.hasData + ); + + return { + connected: connectedArg ?? configArg.connected ?? source?.connected ?? hasManualData, + source: source?.source || (hasManualData ? 'manual' : 'runtime'), + updatedAt: source?.updatedAt || new Date().toISOString(), + router, + devices, + interfaces, + connection, + sensors, + wifiNetworks, + portForwards, + callDeflections, + update, + actions, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: { + ...source?.metadata, + ...configArg.metadata, + liveTr064Implemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }, + }; + } + + public static toDevices(snapshotArg: IFritzSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.routerDevice(snapshotArg, updatedAt)]; + for (const client of snapshotArg.devices) { + devices.push(this.clientDevice(client, snapshotArg, updatedAt)); + } + return devices; + } + + public static toEntities(snapshotArg: IFritzSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const routerDeviceId = this.routerDeviceId(snapshotArg); + const routerName = this.routerName(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + + entities.push(this.entity('binary_sensor', `${routerName} Integration connected`, routerDeviceId, `${uniqueBase}_integration_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.router.host, + port: snapshotArg.router.port, + ssl: snapshotArg.router.ssl, + }, true)); + + if (snapshotArg.sensors.is_connected !== undefined) { + entities.push(this.entity('binary_sensor', `${routerName} Connection`, routerDeviceId, `${uniqueBase}_is_connected`, snapshotArg.sensors.is_connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + entityCategory: 'diagnostic', + nativeKey: 'is_connected', + }, snapshotArg.connected)); + } + if (snapshotArg.sensors.is_linked !== undefined) { + entities.push(this.entity('binary_sensor', `${routerName} Link`, routerDeviceId, `${uniqueBase}_is_linked`, snapshotArg.sensors.is_linked ? 'on' : 'off', usedIds, { + deviceClass: 'plug', + entityCategory: 'diagnostic', + nativeKey: 'is_linked', + }, snapshotArg.connected)); + } + + for (const descriptor of connectionSensorDescriptors) { + const rawValue = snapshotArg.sensors[descriptor.key]; + if (rawValue === undefined) { + continue; + } + const value = this.sensorValue(rawValue, descriptor); + if (value === undefined) { + continue; + } + entities.push(this.entity('sensor', `${routerName} ${descriptor.name}`, routerDeviceId, `${uniqueBase}_${this.slug(descriptor.key)}`, value, usedIds, { + nativeKey: descriptor.key, + unit: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + enabledByDefault: descriptor.enabledByDefault, + }, snapshotArg.connected)); + } + + for (const iface of snapshotArg.interfaces) { + this.pushInterfaceEntities(entities, snapshotArg, iface, usedIds); + } + + for (const client of snapshotArg.devices) { + this.pushClientEntities(entities, snapshotArg, client, usedIds); + } + + for (const wifi of snapshotArg.wifiNetworks) { + this.pushWifiEntities(entities, snapshotArg, wifi, usedIds); + } + + for (const portForward of snapshotArg.portForwards) { + this.pushPortForwardEntities(entities, snapshotArg, portForward, usedIds); + } + + for (const deflection of snapshotArg.callDeflections) { + this.pushCallDeflectionEntities(entities, snapshotArg, deflection, usedIds); + } + + if (this.hasUpdate(snapshotArg)) { + entities.push(this.updateEntity(snapshotArg, usedIds)); + } + + for (const action of this.snapshotActions(snapshotArg)) { + const button = this.actionButton(snapshotArg, action, usedIds); + if (button) { + entities.push(button); + } + } + + return entities; + } + + public static commandForService(snapshotArg: IFritzSnapshot, requestArg: IServiceCallRequest): IFritzCommand | undefined { + if (requestArg.domain === fritzDomain && (requestArg.service === 'reboot' || requestArg.service === 'reconnect' || requestArg.service === 'firmware_update' || requestArg.service === 'cleanup')) { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'router' && actionArg.action === this.routerActionFromService(requestArg.service)); + return action ? this.command(snapshotArg, requestArg, action) : undefined; + } + + if (requestArg.domain === fritzDomain && (requestArg.service === 'set_guest_wifi_password' || requestArg.service === 'dial')) { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'service' && actionArg.action === requestArg.service); + if (!action) { + return undefined; + } + if (requestArg.service === 'set_guest_wifi_password' && !this.validGuestPasswordRequest(requestArg.data)) { + return undefined; + } + if (requestArg.service === 'dial' && !this.stringValue(requestArg.data?.number)) { + return undefined; + } + return this.command(snapshotArg, requestArg, action); + } + + if (requestArg.domain === fritzDomain && requestArg.service === 'wake_on_lan') { + const mac = this.normalizeMac(this.stringValue(requestArg.data?.mac) || this.stringValue(requestArg.data?.macAddress)); + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'client' && actionArg.action === 'wake_on_lan' && (!actionArg.mac || this.normalizeMac(actionArg.mac) === mac)); + return action && mac ? this.command(snapshotArg, requestArg, { ...action, mac }) : undefined; + } + + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + if (!targetEntity) { + return undefined; + } + + if (requestArg.domain === 'button' && requestArg.service === 'press') { + const nativeAction = this.stringValue(targetEntity.attributes?.nativeAction) as TFritzAction | undefined; + if (!nativeAction) { + return undefined; + } + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.entityId === targetEntity.id || actionArg.action === nativeAction && this.actionMatchesEntity(actionArg, targetEntity)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + if ((requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) || requestArg.service === 'set_state') { + const enabled = requestArg.service === 'turn_on' || requestArg.data?.enabled === true || requestArg.data?.state === true; + const action = this.switchActionForEntity(snapshotArg, targetEntity); + return action ? this.command(snapshotArg, requestArg, action, targetEntity, { enabled }) : undefined; + } + + if (requestArg.domain === 'update' && requestArg.service === 'install') { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'router' && actionArg.action === 'firmware_update'); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IFritzEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: fritzDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + public static defaultPort(sslArg = fritzDefaultSsl): number { + return sslArg ? fritzDefaultHttpsPort : fritzDefaultHttpPort; + } + + public static protocol(sslArg = fritzDefaultSsl): TFritzProtocol { + return sslArg ? 'https' : 'http'; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'fritz'; + } + + public static uptimeValue(valueArg: unknown): unknown { + if (valueArg instanceof Date) { + return valueArg.toISOString(); + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return new Date(Date.now() - valueArg * 1000).toISOString(); + } + return valueArg; + } + + private static routerInfo(configArg: IFritzConfig, sourceArg: IFritzSnapshot | undefined, manualSnapshotsArg: IFritzSnapshot[], manualRouterArg?: IFritzRouterInfo): IFritzRouterInfo { + const manualRouter = manualRouterArg || manualSnapshotsArg.find((snapshotArg) => snapshotArg.router)?.router; + const router = { + ...sourceArg?.router, + ...manualRouter, + ...configArg.router, + }; + const ssl = configArg.ssl ?? router.ssl ?? sourceArg?.router.ssl ?? fritzDefaultSsl; + const host = configArg.host || router.host || sourceArg?.router.host; + const port = configArg.port || router.port || (host ? this.defaultPort(ssl) : undefined); + const serialMac = this.normalizeMac(configArg.uniqueId || router.serialNumber || sourceArg?.router.serialNumber); + const mac = this.normalizeMac(router.macAddress || serialMac); + const protocol = this.protocol(ssl); + return { + ...router, + id: router.id || configArg.uniqueId || router.serialNumber || mac || (host ? `${host}:${port || this.defaultPort(ssl)}` : undefined) || configArg.name || 'fritz', + host, + port, + ssl, + name: configArg.name || router.name || configArg.model || router.model || host || 'FRITZ!Box', + model: configArg.model || router.model || 'FRITZ!Box', + macAddress: mac || router.macAddress, + serialNumber: router.serialNumber || configArg.uniqueId, + manufacturer: router.manufacturer || manufacturer, + meshRole: this.meshRole(router.meshRole) || 'none', + isRouter: router.isRouter ?? router.wanEnabled ?? true, + wanEnabled: router.wanEnabled ?? router.isRouter ?? true, + ipv6Active: router.ipv6Active ?? false, + configurationUrl: router.configurationUrl || (host ? `${protocol}://${host}` : undefined), + }; + } + + private static mergeManualEntries(entriesArg: IFritzManualEntry[]): { + router?: IFritzRouterInfo; + devices: IFritzClientDevice[]; + interfaces: IFritzInterfaceStats[]; + connection?: IFritzConnectionInfo; + sensors?: IFritzSensorMap; + wifiNetworks: IFritzWifiNetwork[]; + portForwards: IFritzPortForward[]; + callDeflections: IFritzCallDeflection[]; + update?: IFritzUpdateInfo; + actions: IFritzActionDescriptor[]; + hasData: boolean; + } { + const devices: IFritzClientDevice[] = []; + const interfaces: IFritzInterfaceStats[] = []; + const wifiNetworks: IFritzWifiNetwork[] = []; + const portForwards: IFritzPortForward[] = []; + const callDeflections: IFritzCallDeflection[] = []; + const actions: IFritzActionDescriptor[] = []; + const sensors: IFritzSensorMap = {}; + let router: IFritzRouterInfo | undefined; + let connection: IFritzConnectionInfo | undefined; + let update: IFritzUpdateInfo | undefined; + let hasData = false; + for (const entry of entriesArg) { + if (entry.router) { + router = { ...router, ...entry.router }; + hasData = true; + } else if (!router && (entry.host || entry.name || entry.model || entry.macAddress || entry.serialNumber)) { + router = { + id: entry.id || entry.serialNumber || entry.macAddress || entry.host, + host: entry.host, + port: entry.port, + ssl: entry.ssl, + name: entry.name, + model: entry.model, + serialNumber: entry.serialNumber, + macAddress: entry.macAddress, + manufacturer: entry.manufacturer, + }; + hasData = true; + } + if (entry.connection) { + connection = { ...connection, ...entry.connection }; + hasData = true; + } + if (entry.update) { + update = { ...update, ...entry.update }; + hasData = true; + } + devices.push(...(entry.devices || []), ...(entry.clients || [])); + interfaces.push(...(entry.interfaces || [])); + wifiNetworks.push(...(entry.wifiNetworks || [])); + portForwards.push(...(entry.portForwards || [])); + callDeflections.push(...(entry.callDeflections || [])); + Object.assign(sensors, entry.sensors || {}); + actions.push(...(entry.actions || [])); + hasData = hasData || Boolean(entry.devices?.length || entry.clients?.length || entry.interfaces?.length || entry.sensors || entry.wifiNetworks?.length || entry.portForwards?.length || entry.callDeflections?.length || entry.actions?.length); + } + return { router, devices, interfaces, connection, sensors: Object.keys(sensors).length ? sensors : undefined, wifiNetworks, portForwards, callDeflections, update, actions, hasData }; + } + + private static connectionInfo(sourcesArg: Array, routerArg: IFritzRouterInfo): IFritzConnectionInfo { + const connection: IFritzConnectionInfo = {}; + for (const source of sourcesArg) { + Object.assign(connection, source || {}); + } + connection.connection ||= routerArg.connectionType; + connection.wanEnabled ??= routerArg.wanEnabled ?? routerArg.isRouter; + connection.ipv6Active ??= routerArg.ipv6Active; + return this.cleanAttributes(connection) as IFritzConnectionInfo; + } + + private static sensorMap(sourcesArg: Array, connectionArg: IFritzConnectionInfo, devicesArg: IFritzClientDevice[], interfacesArg: IFritzInterfaceStats[]): IFritzSensorMap { + const sensors: IFritzSensorMap = {}; + for (const source of sourcesArg) { + Object.assign(sensors, source || {}); + } + sensors.is_connected ??= this.booleanValue(connectionArg.isConnected); + sensors.is_linked ??= this.booleanValue(connectionArg.isLinked); + sensors.external_ip ??= this.stringValue(connectionArg.externalIp); + sensors.external_ipv6 ??= this.stringValue(connectionArg.externalIPv6 || connectionArg.externalIpv6); + sensors.connection_uptime ??= connectionArg.connectionUptime; + sensors.device_uptime ??= connectionArg.deviceUptime; + sensors.kb_s_sent ??= this.numberValue(connectionArg.kbSent) ?? this.dividePair(connectionArg.transmissionRate, 0, 1000); + sensors.kb_s_received ??= this.numberValue(connectionArg.kbReceived) ?? this.dividePair(connectionArg.transmissionRate, 1, 1000); + sensors.max_kb_s_sent ??= this.numberValue(connectionArg.maxKbSent) ?? this.dividePair(connectionArg.maxBitRate, 0, 1000); + sensors.max_kb_s_received ??= this.numberValue(connectionArg.maxKbReceived) ?? this.dividePair(connectionArg.maxBitRate, 1, 1000); + sensors.link_kb_s_sent ??= this.numberValue(connectionArg.linkKbSent) ?? this.dividePair(connectionArg.maxLinkedBitRate, 0, 1000); + sensors.link_kb_s_received ??= this.numberValue(connectionArg.linkKbReceived) ?? this.dividePair(connectionArg.maxLinkedBitRate, 1, 1000); + sensors.link_noise_margin_sent ??= this.dividePair(connectionArg.noiseMargin, 0, 10); + sensors.link_noise_margin_received ??= this.dividePair(connectionArg.noiseMargin, 1, 10); + sensors.link_attenuation_sent ??= this.dividePair(connectionArg.attenuation, 0, 10); + sensors.link_attenuation_received ??= this.dividePair(connectionArg.attenuation, 1, 10); + sensors.gb_sent ??= this.numberValue(connectionArg.gbSent) ?? this.bytesToGigabytes(connectionArg.bytesSent); + sensors.gb_received ??= this.numberValue(connectionArg.gbReceived) ?? this.bytesToGigabytes(connectionArg.bytesReceived); + sensors.cpu_temperature ??= this.numberValue(connectionArg.cpuTemperature); + if (interfacesArg.length) { + sensors.gb_received ??= this.bytesToGigabytes(this.sumInterfaces(interfacesArg, 'rxBytes', 'downloadBytes')); + sensors.gb_sent ??= this.bytesToGigabytes(this.sumInterfaces(interfacesArg, 'txBytes', 'uploadBytes')); + sensors.kb_s_received ??= this.sumInterfaces(interfacesArg, 'rxRateKbps', 'downloadRateKbps'); + sensors.kb_s_sent ??= this.sumInterfaces(interfacesArg, 'txRateKbps', 'uploadRateKbps'); + } + if (sensors.is_connected === undefined && devicesArg.length) { + sensors.is_connected = true; + } + return this.cleanAttributes(sensors) as IFritzSensorMap; + } + + private static updateInfo(...sourcesArg: Array): IFritzUpdateInfo | undefined { + const update: IFritzUpdateInfo = {}; + for (const source of sourcesArg) { + if (!source) { + continue; + } + const router = source as IFritzRouterInfo; + const updateSource = source as IFritzUpdateInfo; + update.installedVersion = updateSource.installedVersion || router.currentFirmware || router.firmware || update.installedVersion; + update.latestVersion = updateSource.latestVersion || router.latestFirmware || update.latestVersion; + update.releaseUrl = updateSource.releaseUrl || router.releaseUrl || update.releaseUrl; + update.updateAvailable = updateSource.updateAvailable ?? router.updateAvailable ?? update.updateAvailable; + update.metadata = { ...update.metadata, ...updateSource.metadata }; + } + const cleaned = this.cleanAttributes(update as Record) as IFritzUpdateInfo; + return Object.keys(cleaned).length ? cleaned : undefined; + } + + private static routerDevice(snapshotArg: IFritzSnapshot, 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: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + ]; + this.addFeatureState(features, state, 'external_ip', 'External IP', snapshotArg.sensors.external_ip, updatedAtArg); + this.addFeatureState(features, state, 'download_throughput', 'Download throughput', snapshotArg.sensors.kb_s_received, updatedAtArg, 'kB/s'); + this.addFeatureState(features, state, 'upload_throughput', 'Upload throughput', snapshotArg.sensors.kb_s_sent, updatedAtArg, 'kB/s'); + this.addFeatureState(features, state, 'download_total', 'GB received', snapshotArg.sensors.gb_received, updatedAtArg, 'GB'); + this.addFeatureState(features, state, 'upload_total', 'GB sent', snapshotArg.sensors.gb_sent, updatedAtArg, 'GB'); + this.addFeatureState(features, state, 'cpu_temperature', 'CPU temperature', snapshotArg.sensors.cpu_temperature, updatedAtArg, 'C'); + if (this.hasUpdate(snapshotArg)) { + features.push({ id: 'firmware_update', capability: 'switch', name: 'Firmware update available', readable: true, writable: snapshotArg.router.actions?.includes('firmware_update') || false }); + state.push({ featureId: 'firmware_update', value: snapshotArg.update?.updateAvailable === true, updatedAt: updatedAtArg }); + } + for (const wifi of snapshotArg.wifiNetworks) { + const key = `wifi_${this.slug(this.wifiId(wifi))}`; + features.push({ id: key, capability: 'switch', name: `Wi-Fi ${this.wifiName(wifi)}`, readable: true, writable: true }); + state.push({ featureId: key, value: wifi.enabled !== false, updatedAt: updatedAtArg }); + } + + return { + id: this.routerDeviceId(snapshotArg), + integrationDomain: fritzDomain, + name: this.routerName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.router.manufacturer || manufacturer, + model: snapshotArg.router.model || 'FRITZ!Box', + online: snapshotArg.connected, + features, + state, + metadata: this.cleanAttributes({ + host: snapshotArg.router.host, + port: snapshotArg.router.port, + ssl: snapshotArg.router.ssl, + macAddress: snapshotArg.router.macAddress, + serialNumber: snapshotArg.router.serialNumber, + firmware: snapshotArg.router.firmware || snapshotArg.router.currentFirmware, + latestFirmware: snapshotArg.router.latestFirmware || snapshotArg.update?.latestVersion, + configurationUrl: snapshotArg.router.configurationUrl, + meshRole: snapshotArg.router.meshRole, + connectionType: snapshotArg.router.connectionType || snapshotArg.connection.connection, + source: snapshotArg.source, + liveTr064Implemented: false, + }), + }; + } + + private static clientDevice(clientArg: IFritzClientDevice, snapshotArg: IFritzSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'ip_address', capability: 'sensor', name: 'IP Address', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'presence', value: clientArg.connected !== false, updatedAt: updatedAtArg }, + { featureId: 'ip_address', value: clientArg.ipAddress || clientArg.ip || null, updatedAt: updatedAtArg }, + ]; + if (clientArg.wanAccess !== undefined && clientArg.wanAccess !== null) { + features.push({ id: 'internet_access', capability: 'switch', name: 'Internet access', readable: true, writable: true }); + state.push({ featureId: 'internet_access', value: clientArg.wanAccess, updatedAt: updatedAtArg }); + } + if (this.clientHasAction(clientArg, 'wake_on_lan')) { + features.push({ id: 'wake_on_lan', capability: 'switch', name: 'Wake on LAN', readable: false, writable: true }); + state.push({ featureId: 'wake_on_lan', value: false, updatedAt: updatedAtArg }); + } + + return { + id: this.clientDeviceId(clientArg), + integrationDomain: fritzDomain, + name: this.clientName(clientArg), + protocol: 'unknown', + manufacturer: clientArg.manufacturer || manufacturer, + model: clientArg.model || 'FRITZ!Box tracked device', + online: clientArg.connected !== false && snapshotArg.connected, + features, + state, + metadata: this.cleanAttributes({ + mac: this.clientMac(clientArg), + ipAddress: clientArg.ipAddress || clientArg.ip, + hostname: clientArg.hostname || clientArg.name, + connectedTo: clientArg.connectedTo, + connectionType: clientArg.connectionType, + ssid: clientArg.ssid, + lastActivity: this.dateString(clientArg.lastActivity), + }), + }; + } + + private static pushInterfaceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IFritzSnapshot, ifaceArg: IFritzInterfaceStats, usedIdsArg: Map): void { + const deviceId = this.routerDeviceId(snapshotArg); + const ifaceKey = this.slug(ifaceArg.id || ifaceArg.name); + const ifaceName = ifaceArg.label || ifaceArg.name; + const values: Array<[string, string, unknown, string | undefined, Record]> = [ + ['rx_bytes', 'Download', this.bytesToGigabytes(ifaceArg.rxBytes ?? ifaceArg.downloadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['tx_bytes', 'Upload', this.bytesToGigabytes(ifaceArg.txBytes ?? ifaceArg.uploadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['rx_rate', 'Download throughput', ifaceArg.rxRateKbps ?? ifaceArg.downloadRateKbps, 'kB/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ['tx_rate', 'Upload throughput', ifaceArg.txRateKbps ?? ifaceArg.uploadRateKbps, 'kB/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ]; + for (const [key, name, value, unit, attrs] of values) { + if (value === undefined) { + continue; + } + entitiesArg.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${ifaceName} ${name}`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_${key}`, value, usedIdsArg, { + ...attrs, + unit, + interface: ifaceArg.name, + interfaceType: ifaceArg.type, + ssid: ifaceArg.ssid, + }, snapshotArg.connected && ifaceArg.connected !== false)); + } + if (ifaceArg.connected !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} ${ifaceName} Link`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_link`, ifaceArg.connected ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + interface: ifaceArg.name, + interfaceType: ifaceArg.type, + }, snapshotArg.connected)); + } + } + + private static pushClientEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IFritzSnapshot, clientArg: IFritzClientDevice, usedIdsArg: Map): void { + const mac = this.clientMac(clientArg); + const deviceId = this.clientDeviceId(clientArg); + entitiesArg.push(this.entity('binary_sensor', `${this.clientName(clientArg)} Connected`, deviceId, `${this.slug(mac || this.clientName(clientArg))}_connected`, clientArg.connected !== false ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + nativePlatform: 'device_tracker', + mac, + ipAddress: clientArg.ipAddress || clientArg.ip, + hostname: clientArg.hostname || clientArg.name, + connectedTo: clientArg.connectedTo, + connectionType: clientArg.connectionType, + ssid: clientArg.ssid, + lastActivity: this.dateString(clientArg.lastActivity), + }, snapshotArg.connected)); + if (clientArg.wanAccess !== undefined && clientArg.wanAccess !== null) { + entitiesArg.push(this.entity('switch', `${this.clientName(clientArg)} Internet access`, deviceId, `${this.slug(mac || this.clientName(clientArg))}_internet_access`, clientArg.wanAccess ? 'on' : 'off', usedIdsArg, { + nativeType: 'client_wan_access', + nativeAction: 'set_wan_access', + mac, + ipAddress: clientArg.ipAddress || clientArg.ip, + writable: true, + }, snapshotArg.connected && Boolean(clientArg.ipAddress || clientArg.ip))); + } + } + + private static pushWifiEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IFritzSnapshot, wifiArg: IFritzWifiNetwork, usedIdsArg: Map): void { + const name = `${this.routerName(snapshotArg)} Wi-Fi ${this.wifiName(wifiArg)}`; + entitiesArg.push(this.entity('switch', name, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_wi_fi_${this.slug(this.wifiId(wifiArg))}`, wifiArg.enabled === false ? 'off' : 'on', usedIdsArg, { + nativeType: 'wifi_network', + nativeAction: 'set_wifi_enabled', + networkId: this.wifiId(wifiArg), + networkIndex: wifiArg.index, + ssid: wifiArg.ssid, + standard: wifiArg.standard, + bssid: wifiArg.bssid, + macAddressControl: wifiArg.macAddressControl, + guest: wifiArg.guest, + band: wifiArg.band, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + + private static pushPortForwardEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IFritzSnapshot, portArg: IFritzPortForward, usedIdsArg: Map): void { + const name = `${this.routerName(snapshotArg)} Port forward ${this.portForwardName(portArg)}`; + entitiesArg.push(this.entity('switch', name, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_port_forward_${this.slug(this.portForwardId(portArg))}`, portArg.enabled ? 'on' : 'off', usedIdsArg, { + nativeType: 'port_forward', + nativeAction: 'set_port_forward_enabled', + portForwardId: this.portForwardId(portArg), + index: portArg.index, + connectionType: portArg.connectionType, + internalClient: portArg.internalClient, + internalPort: portArg.internalPort, + externalPort: portArg.externalPort, + protocol: portArg.protocol, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + + private static pushCallDeflectionEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IFritzSnapshot, deflectionArg: IFritzCallDeflection, usedIdsArg: Map): void { + const name = `${this.routerName(snapshotArg)} Call deflection ${deflectionArg.id}`; + entitiesArg.push(this.entity('switch', name, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_call_deflection_${this.slug(String(deflectionArg.id))}`, deflectionArg.enabled ? 'on' : 'off', usedIdsArg, { + nativeType: 'call_deflection', + nativeAction: 'set_call_deflection_enabled', + deflectionId: deflectionArg.id, + type: deflectionArg.type, + number: deflectionArg.number, + deflectionToNumber: deflectionArg.deflectionToNumber, + mode: deflectionArg.mode, + outgoing: deflectionArg.outgoing, + phonebookId: deflectionArg.phonebookId, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + + private static updateEntity(snapshotArg: IFritzSnapshot, usedIdsArg: Map): IIntegrationEntity { + const update = snapshotArg.update || {}; + const installedVersion = update.installedVersion || snapshotArg.router.currentFirmware || snapshotArg.router.firmware; + const latestVersion = update.updateAvailable ? update.latestVersion || snapshotArg.router.latestFirmware : installedVersion; + return this.entity('update', `${this.routerName(snapshotArg)} FRITZ!OS`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_fritz_os_update`, update.updateAvailable ? 'on' : 'off', usedIdsArg, { + nativeType: 'firmware_update', + nativeAction: 'firmware_update', + installedVersion, + latestVersion, + releaseUrl: update.releaseUrl || snapshotArg.router.releaseUrl, + entityCategory: 'config', + writable: this.snapshotActions(snapshotArg).some((actionArg) => actionArg.target === 'router' && actionArg.action === 'firmware_update'), + }, snapshotArg.connected); + } + + private static actionButton(snapshotArg: IFritzSnapshot, actionArg: IFritzActionDescriptor, usedIdsArg: Map): IIntegrationEntity | undefined { + if (actionArg.target === 'router' && this.isRouterAction(actionArg.action)) { + return this.entity('button', `${this.routerName(snapshotArg)} ${this.title(actionArg.action)}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_${this.slug(actionArg.action)}`, 'available', usedIdsArg, { + nativeType: 'router_action', + nativeAction: actionArg.action, + actionTarget: 'router', + entityCategory: 'config', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + if (actionArg.target !== 'client' || actionArg.action !== 'wake_on_lan' || !actionArg.mac) { + return undefined; + } + const client = snapshotArg.devices.find((clientArg) => this.clientMac(clientArg) === this.normalizeMac(actionArg.mac)); + if (!client) { + return undefined; + } + return this.entity('button', `${this.clientName(client)} Wake on LAN`, this.clientDeviceId(client), `${this.slug(actionArg.mac)}_wake_on_lan`, 'available', usedIdsArg, { + nativeType: 'client_action', + nativeAction: 'wake_on_lan', + actionTarget: 'client', + mac: this.clientMac(client), + entityCategory: 'config', + enabledByDefault: false, + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + + private static command(snapshotArg: IFritzSnapshot, requestArg: IServiceCallRequest, actionArg: IFritzActionDescriptor, entityArg?: IIntegrationEntity, payloadArg: Record = {}): IFritzCommand { + return { + type: actionArg.target === 'router' ? 'router.action' : actionArg.target === 'client' ? 'client.action' : actionArg.target === 'service' ? 'service.action' : 'switch.set', + service: requestArg.service, + action: actionArg.action, + target: requestArg.target, + routerId: this.routerDeviceId(snapshotArg), + mac: actionArg.mac ? this.normalizeMac(actionArg.mac) : this.normalizeMac(this.stringValue(entityArg?.attributes?.mac)), + entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId, + deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId, + payload: this.cleanAttributes({ ...(requestArg.data || {}), ...payloadArg, actionId: actionArg.id, actionMetadata: actionArg.metadata }), + }; + } + + private static switchActionForEntity(snapshotArg: IFritzSnapshot, entityArg: IIntegrationEntity): IFritzActionDescriptor | undefined { + const nativeType = this.stringValue(entityArg.attributes?.nativeType); + const actions = this.snapshotActions(snapshotArg); + if (nativeType === 'wifi_network') { + const id = this.stringValue(entityArg.attributes?.networkId); + return actions.find((actionArg) => actionArg.target === 'wifi' && actionArg.action === 'set_wifi_enabled' && String(actionArg.id) === String(id)); + } + if (nativeType === 'port_forward') { + const id = this.stringValue(entityArg.attributes?.portForwardId); + return actions.find((actionArg) => actionArg.target === 'port_forward' && actionArg.action === 'set_port_forward_enabled' && String(actionArg.id) === String(id)); + } + if (nativeType === 'call_deflection') { + const id = this.stringValue(entityArg.attributes?.deflectionId); + return actions.find((actionArg) => actionArg.target === 'call_deflection' && actionArg.action === 'set_call_deflection_enabled' && String(actionArg.id) === String(id)); + } + if (nativeType === 'client_wan_access') { + const mac = this.normalizeMac(this.stringValue(entityArg.attributes?.mac)); + return actions.find((actionArg) => actionArg.target === 'client' && actionArg.action === 'set_wan_access' && (!actionArg.mac || this.normalizeMac(actionArg.mac) === mac)); + } + return undefined; + } + + private static actionMatchesEntity(actionArg: IFritzActionDescriptor, entityArg: IIntegrationEntity): boolean { + if (actionArg.target === 'router') { + return entityArg.attributes?.actionTarget === 'router'; + } + if (actionArg.target === 'client') { + return this.normalizeMac(actionArg.mac) === this.normalizeMac(this.stringValue(entityArg.attributes?.mac)); + } + return false; + } + + private static findTargetEntity(snapshotArg: IFritzSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const targetEntityId = requestArg.target.entityId; + if (!targetEntityId) { + return undefined; + } + return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === targetEntityId || entityArg.uniqueId === targetEntityId); + } + + private static routerActionFromService(serviceArg: string): TFritzRouterAction | undefined { + if (serviceArg === 'reboot' || serviceArg === 'reconnect' || serviceArg === 'firmware_update' || serviceArg === 'cleanup') { + return serviceArg; + } + return undefined; + } + + private static actionsFromRouter(routerArg: IFritzRouterInfo): IFritzActionDescriptor[] { + return (routerArg.actions || []).map((actionArg) => ({ target: 'router', action: actionArg })); + } + + private static actionsFromClients(devicesArg: IFritzClientDevice[]): IFritzActionDescriptor[] { + const actions: IFritzActionDescriptor[] = []; + for (const device of devicesArg) { + const mac = this.clientMac(device); + if (!mac) { + continue; + } + for (const action of device.actions || []) { + actions.push({ target: 'client', action, mac }); + } + if (device.connectionType?.toLowerCase() === 'lan') { + actions.push({ target: 'client', action: 'wake_on_lan', mac }); + } + if (device.wanAccess !== undefined && device.wanAccess !== null) { + actions.push({ target: 'client', action: 'set_wan_access', mac }); + } + } + return actions; + } + + private static actionsFromRepresentedControls(wifiArg: IFritzWifiNetwork[], portArg: IFritzPortForward[], deflectionsArg: IFritzCallDeflection[]): IFritzActionDescriptor[] { + return [ + ...wifiArg.map((wifiEntryArg) => ({ target: 'wifi' as const, action: 'set_wifi_enabled' as const, id: this.wifiId(wifiEntryArg) })), + ...portArg.map((portEntryArg) => ({ target: 'port_forward' as const, action: 'set_port_forward_enabled' as const, id: this.portForwardId(portEntryArg) })), + ...deflectionsArg.map((deflectionArg) => ({ target: 'call_deflection' as const, action: 'set_call_deflection_enabled' as const, id: deflectionArg.id })), + ]; + } + + private static snapshotActions(snapshotArg: IFritzSnapshot): IFritzActionDescriptor[] { + return this.uniqueActions([ + ...(snapshotArg.actions || []), + ...this.actionsFromRouter(snapshotArg.router), + ...this.actionsFromClients(snapshotArg.devices), + ...this.actionsFromRepresentedControls(snapshotArg.wifiNetworks, snapshotArg.portForwards, snapshotArg.callDeflections), + ]); + } + + private static uniqueClients(devicesArg: IFritzClientDevice[]): IFritzClientDevice[] { + const seen = new Map(); + for (const device of devicesArg) { + const key = this.clientMac(device) || device.id || device.ipAddress || device.ip || device.name || device.hostname; + if (!key) { + continue; + } + seen.set(key, { ...seen.get(key), ...device, mac: this.clientMac(device) || device.mac }); + } + return [...seen.values()]; + } + + private static uniqueInterfaces(interfacesArg: IFritzInterfaceStats[]): IFritzInterfaceStats[] { + const seen = new Map(); + for (const iface of interfacesArg) { + const key = iface.id || iface.name; + if (!key) { + continue; + } + seen.set(key, { ...seen.get(key), ...iface }); + } + return [...seen.values()]; + } + + private static uniqueWifiNetworks(networksArg: IFritzWifiNetwork[]): IFritzWifiNetwork[] { + const seen = new Map(); + for (const network of networksArg) { + const key = this.wifiId(network); + seen.set(key, { ...seen.get(key), ...network }); + } + return [...seen.values()]; + } + + private static uniquePortForwards(portForwardsArg: IFritzPortForward[]): IFritzPortForward[] { + const seen = new Map(); + for (const portForward of portForwardsArg) { + const key = this.portForwardId(portForward); + seen.set(key, { ...seen.get(key), ...portForward }); + } + return [...seen.values()]; + } + + private static uniqueCallDeflections(deflectionsArg: IFritzCallDeflection[]): IFritzCallDeflection[] { + const seen = new Map(); + for (const deflection of deflectionsArg) { + seen.set(String(deflection.id), { ...seen.get(String(deflection.id)), ...deflection }); + } + return [...seen.values()]; + } + + private static uniqueActions(actionsArg: IFritzActionDescriptor[]): IFritzActionDescriptor[] { + const seen = new Map(); + for (const action of actionsArg) { + const mac = this.normalizeMac(action.mac); + const key = [action.target, action.action, mac || action.id || action.entityId || action.deviceId || 'router'].join(':'); + seen.set(key, { ...action, mac }); + } + return [...seen.values()]; + } + + private static routerDeviceId(snapshotArg: IFritzSnapshot): string { + return `${fritzDomain}.router.${this.uniqueBase(snapshotArg)}`; + } + + private static clientDeviceId(clientArg: IFritzClientDevice): string { + return `${fritzDomain}.client.${this.slug(this.clientMac(clientArg) || clientArg.id || clientArg.ipAddress || clientArg.ip || this.clientName(clientArg))}`; + } + + private static routerName(snapshotArg: IFritzSnapshot): string { + return snapshotArg.router.name || snapshotArg.router.host || 'FRITZ!Box'; + } + + private static clientName(clientArg: IFritzClientDevice): string { + return clientArg.name || clientArg.hostname || clientArg.macAddress || clientArg.mac || clientArg.ipAddress || clientArg.ip || 'Unknown device'; + } + + private static clientMac(clientArg: IFritzClientDevice): string | undefined { + return this.normalizeMac(clientArg.macAddress || clientArg.mac); + } + + private static uniqueBase(snapshotArg: IFritzSnapshot): string { + return this.slug(snapshotArg.router.macAddress || snapshotArg.router.serialNumber || snapshotArg.router.id || snapshotArg.router.host || this.routerName(snapshotArg)); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record = {}, availableArg = true, explicitIdArg?: string): IIntegrationEntity { + const baseId = explicitIdArg || `${platformArg}.${this.slug(nameArg)}`; + const used = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, used + 1); + return { + id: used ? `${baseId}_${used + 1}` : baseId, + uniqueId: `${fritzDomain}_${this.slug(uniqueIdArg)}`, + integrationDomain: fritzDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static wifiId(wifiArg: IFritzWifiNetwork): string { + return String(wifiArg.id || wifiArg.index || wifiArg.switchName || wifiArg.name || wifiArg.ssid || 'wifi'); + } + + private static wifiName(wifiArg: IFritzWifiNetwork): string { + return wifiArg.switchName || wifiArg.name || wifiArg.ssid || (wifiArg.index ? `Network ${wifiArg.index}` : 'Network'); + } + + private static portForwardId(portArg: IFritzPortForward): string { + return String(portArg.id || portArg.index || [portArg.connectionType, portArg.internalClient, portArg.externalPort, portArg.protocol, portArg.description].filter(Boolean).join('_') || 'port_forward'); + } + + private static portForwardName(portArg: IFritzPortForward): string { + return portArg.description || [portArg.externalPort, portArg.protocol].filter(Boolean).join('/') || this.portForwardId(portArg); + } + + private static hasUpdate(snapshotArg: IFritzSnapshot): boolean { + return Boolean(snapshotArg.update || snapshotArg.router.currentFirmware || snapshotArg.router.firmware || snapshotArg.router.latestFirmware || snapshotArg.router.updateAvailable !== undefined); + } + + private static clientHasAction(clientArg: IFritzClientDevice, actionArg: TFritzClientAction): boolean { + return Boolean(clientArg.actions?.includes(actionArg) || actionArg === 'wake_on_lan' && clientArg.connectionType?.toLowerCase() === 'lan'); + } + + private static isRouterAction(actionArg: TFritzAction): actionArg is TFritzRouterAction { + return actionArg === 'firmware_update' || actionArg === 'reboot' || actionArg === 'reconnect' || actionArg === 'cleanup'; + } + + private static validGuestPasswordRequest(dataArg?: Record): boolean { + const password = this.stringValue(dataArg?.password); + const length = this.numberValue(dataArg?.length); + if (password && (password.length < 8 || password.length > 63)) { + return false; + } + if (length !== undefined && (length < 8 || length > 63)) { + return false; + } + return true; + } + + private static sensorValue(valueArg: unknown, descriptorArg: TSensorDescriptor | undefined): unknown { + if (valueArg === undefined || valueArg === null) { + return valueArg; + } + return descriptorArg?.transform ? descriptorArg.transform(valueArg) : valueArg; + } + + private static addFeatureState(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, updatedAtArg: string, unitArg?: string): void { + if (valueArg === undefined) { + return; + } + featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg }); + stateArg.push({ featureId: idArg, 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) { + return valueArg; + } + if (valueArg && typeof valueArg === 'object') { + return valueArg as Record; + } + return null; + } + + private static sumInterfaces(interfacesArg: IFritzInterfaceStats[], primaryKeyArg: keyof IFritzInterfaceStats, fallbackKeyArg: keyof IFritzInterfaceStats): number | undefined { + let total = 0; + let found = false; + for (const iface of interfacesArg) { + const value = this.numberValue(iface[primaryKeyArg]) ?? this.numberValue(iface[fallbackKeyArg]); + if (value !== undefined) { + total += value; + found = true; + } + } + return found ? total : undefined; + } + + private static bytesToGigabytes(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : Math.round(valueArg / 100000000) / 10; + } + + private static dividePair(valueArg: unknown, indexArg: 0 | 1, divisorArg: number): number | undefined { + if (!Array.isArray(valueArg)) { + return undefined; + } + const value = this.numberValue(valueArg[indexArg]); + return value === undefined ? undefined : Math.round(value / divisorArg * 10) / 10; + } + + private static stringValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string' && valueArg.trim()) { + return valueArg.trim(); + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return String(valueArg); + } + return undefined; + } + + private static 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 static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + if (typeof valueArg === 'string') { + const normalized = valueArg.toLowerCase(); + if (['true', '1', 'on', 'yes', 'connected', 'up'].includes(normalized)) { + return true; + } + if (['false', '0', 'off', 'no', 'disconnected', 'down'].includes(normalized)) { + return false; + } + } + return undefined; + } + + private static dateString(valueArg: IFritzClientDevice['lastActivity']): string | undefined { + if (valueArg instanceof Date) { + return valueArg.toISOString(); + } + if (typeof valueArg === 'number') { + return new Date(valueArg).toISOString(); + } + return typeof valueArg === 'string' ? valueArg : undefined; + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static meshRole(valueArg: unknown): TFritzMeshRole | undefined { + return valueArg === 'none' || valueArg === 'master' || valueArg === 'slave' ? valueArg : undefined; + } + + private static cleanAttributes>(attributesArg: TValue): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } +} diff --git a/ts/integrations/fritz/fritz.types.ts b/ts/integrations/fritz/fritz.types.ts index 900892a..6d3d4f6 100644 --- a/ts/integrations/fritz/fritz.types.ts +++ b/ts/integrations/fritz/fritz.types.ts @@ -1,4 +1,369 @@ -export interface IHomeAssistantFritzConfig { - // TODO: replace with the TypeScript-native config for fritz. +import type { IServiceCallResult } from '../../core/types.js'; + +export const fritzDomain = 'fritz'; +export const fritzDefaultHost = '192.168.178.1'; +export const fritzDefaultHttpPort = 49000; +export const fritzDefaultHttpsPort = 49443; +export const fritzDefaultSsl = false; +export const fritzDefaultConsiderHomeSeconds = 180; + +export type TFritzProtocol = 'http' | 'https'; +export type TFritzMeshRole = 'none' | 'master' | 'slave'; +export type TFritzSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime'; +export type TFritzCommandType = 'router.action' | 'client.action' | 'switch.set' | 'service.action'; +export type TFritzRouterAction = 'firmware_update' | 'reboot' | 'reconnect' | 'cleanup'; +export type TFritzClientAction = 'wake_on_lan' | 'set_wan_access'; +export type TFritzSwitchAction = 'set_wifi_enabled' | 'set_port_forward_enabled' | 'set_call_deflection_enabled'; +export type TFritzServiceAction = 'set_guest_wifi_password' | 'dial'; +export type TFritzAction = TFritzRouterAction | TFritzClientAction | TFritzSwitchAction | TFritzServiceAction; + +export interface IFritzConfig { + host?: string; + port?: number; + ssl?: boolean; + username?: string; + password?: string; + featureDeviceTracking?: boolean; + oldDiscovery?: boolean; + considerHomeSeconds?: number; + connected?: boolean; + uniqueId?: string; + name?: string; + model?: string; + snapshot?: IFritzSnapshot; + router?: IFritzRouterInfo; + devices?: IFritzClientDevice[]; + clients?: IFritzClientDevice[]; + interfaces?: IFritzInterfaceStats[]; + connection?: IFritzConnectionInfo; + sensors?: IFritzSensorMap; + wifiNetworks?: IFritzWifiNetwork[]; + portForwards?: IFritzPortForward[]; + callDeflections?: IFritzCallDeflection[]; + update?: IFritzUpdateInfo; + actions?: IFritzActionDescriptor[]; + manualEntries?: IFritzManualEntry[]; + events?: IFritzEvent[]; + snapshotProvider?: TFritzSnapshotProvider; + commandExecutor?: TFritzCommandExecutor; + nativeClient?: IFritzNativeClient; + metadata?: Record; [key: string]: unknown; } + +export interface IHomeAssistantFritzConfig extends IFritzConfig {} + +export interface IFritzRouterInfo { + id?: string; + host?: string; + port?: number; + ssl?: boolean; + name?: string; + model?: string; + serialNumber?: string; + firmware?: string; + currentFirmware?: string; + latestFirmware?: string; + updateAvailable?: boolean; + releaseUrl?: string; + macAddress?: string; + configurationUrl?: string; + manufacturer?: string; + meshRole?: TFritzMeshRole; + meshWifiUplink?: boolean; + connectionType?: string; + isRouter?: boolean; + wanEnabled?: boolean; + ipv6Active?: boolean; + upnpEnabled?: boolean; + services?: string[]; + actions?: TFritzRouterAction[]; + metadata?: Record; +} + +export interface IFritzConnectionInfo { + connection?: string; + wanEnabled?: boolean; + ipv6Active?: boolean; + isConnected?: boolean; + isLinked?: boolean; + externalIp?: string; + externalIPv6?: string; + externalIpv6?: string; + connectionUptime?: string | number | Date; + deviceUptime?: string | number | Date; + transmissionRate?: [number, number]; + maxBitRate?: [number, number]; + maxLinkedBitRate?: [number, number]; + noiseMargin?: [number, number]; + attenuation?: [number, number]; + bytesSent?: number; + bytesReceived?: number; + kbSent?: number; + kbReceived?: number; + maxKbSent?: number; + maxKbReceived?: number; + linkKbSent?: number; + linkKbReceived?: number; + gbSent?: number; + gbReceived?: number; + cpuTemperature?: number; + [key: string]: unknown; +} + +export interface IFritzSensorMap { + is_connected?: boolean; + is_linked?: boolean; + external_ip?: string; + external_ipv6?: string; + connection_uptime?: string | number | Date; + device_uptime?: string | number | Date; + kb_s_sent?: number; + kb_s_received?: number; + max_kb_s_sent?: number; + max_kb_s_received?: number; + gb_sent?: number; + gb_received?: number; + link_kb_s_sent?: number; + link_kb_s_received?: number; + link_noise_margin_sent?: number; + link_noise_margin_received?: number; + link_attenuation_sent?: number; + link_attenuation_received?: number; + cpu_temperature?: number; + [key: string]: string | number | boolean | Date | null | undefined; +} + +export interface IFritzClientDevice { + id?: string; + mac?: string; + macAddress?: string; + name?: string; + hostname?: string; + ip?: string; + ipAddress?: string; + connected?: boolean; + connectedTo?: string; + connectionType?: string; + ssid?: string | null; + lastActivity?: string | number | Date; + wanAccess?: boolean | null; + manufacturer?: string; + model?: string; + actions?: TFritzClientAction[]; + metadata?: Record; + [key: string]: unknown; +} + +export interface IFritzInterfaceStats { + id?: string; + name: string; + label?: string; + type?: string; + connected?: boolean; + macAddress?: string; + ipAddress?: string; + ssid?: string | null; + opMode?: string; + rxBytes?: number; + txBytes?: number; + rxRateKbps?: number; + txRateKbps?: number; + downloadBytes?: number; + uploadBytes?: number; + downloadRateKbps?: number; + uploadRateKbps?: number; + metadata?: Record; +} + +export interface IFritzWifiNetwork { + id?: string; + index?: number; + name?: string; + switchName?: string; + ssid?: string; + enabled?: boolean; + guest?: boolean; + band?: string; + standard?: string; + bssid?: string; + macAddressControl?: boolean; + hidden?: boolean; + metadata?: Record; +} + +export interface IFritzPortForward { + id?: string; + index?: number; + connectionType?: string; + description?: string; + enabled?: boolean; + internalClient?: string; + internalPort?: number; + externalPort?: number; + protocol?: string; + remoteHost?: string; + leaseDuration?: number; + metadata?: Record; +} + +export interface IFritzCallDeflection { + id: string | number; + enabled?: boolean; + type?: string; + number?: string; + deflectionToNumber?: string; + mode?: string; + outgoing?: string; + phonebookId?: string | number; + metadata?: Record; +} + +export interface IFritzUpdateInfo { + installedVersion?: string; + latestVersion?: string; + releaseUrl?: string; + updateAvailable?: boolean; + metadata?: Record; +} + +export interface IFritzActionDescriptor { + target: 'router' | 'client' | 'wifi' | 'port_forward' | 'call_deflection' | 'service'; + action: TFritzAction; + service?: string; + mac?: string; + entityId?: string; + deviceId?: string; + id?: string | number; + label?: string; + metadata?: Record; +} + +export interface IFritzSnapshot { + connected: boolean; + source?: TFritzSnapshotSource; + updatedAt?: string; + router: IFritzRouterInfo; + devices: IFritzClientDevice[]; + interfaces: IFritzInterfaceStats[]; + connection: IFritzConnectionInfo; + sensors: IFritzSensorMap; + wifiNetworks: IFritzWifiNetwork[]; + portForwards: IFritzPortForward[]; + callDeflections: IFritzCallDeflection[]; + update?: IFritzUpdateInfo; + actions?: IFritzActionDescriptor[]; + events?: IFritzEvent[]; + error?: string; + metadata?: Record; +} + +export interface IFritzManualEntry { + id?: string; + host?: string; + port?: number; + ssl?: boolean; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + router?: IFritzRouterInfo; + devices?: IFritzClientDevice[]; + clients?: IFritzClientDevice[]; + interfaces?: IFritzInterfaceStats[]; + connection?: IFritzConnectionInfo; + sensors?: IFritzSensorMap; + wifiNetworks?: IFritzWifiNetwork[]; + portForwards?: IFritzPortForward[]; + callDeflections?: IFritzCallDeflection[]; + update?: IFritzUpdateInfo; + actions?: IFritzActionDescriptor[]; + snapshot?: IFritzSnapshot; + metadata?: Record; + [key: string]: unknown; +} + +export interface IFritzManualDiscoveryRecord extends IFritzManualEntry { + integrationDomain?: string; +} + +export interface IFritzSsdpDiscoveryRecord { + source?: string; + integrationDomain?: string; + id?: string; + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + ssdpLocation?: string; + ssdp_location?: string; + location?: string; + st?: string; + usn?: string; + udn?: string; + upnp?: Record; + metadata?: Record; + [key: string]: unknown; +} + +export interface IFritzMdnsDiscoveryRecord { + source?: string; + integrationDomain?: string; + id?: string; + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + serviceType?: string; + type?: string; + fullname?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface IFritzCommand { + type: TFritzCommandType; + service: string; + action: TFritzAction; + target: { + entityId?: string; + deviceId?: string; + }; + routerId?: string; + mac?: string; + entityId?: string; + deviceId?: string; + payload?: Record; +} + +export interface IFritzCommandResult extends IServiceCallResult {} + +export interface IFritzEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + command?: IFritzCommand; + snapshot?: IFritzSnapshot; + error?: string; + data?: unknown; + [key: string]: unknown; +} + +export interface IFritzNativeClient { + getSnapshot(): Promise | IFritzSnapshot; + executeCommand?(commandArg: IFritzCommand): Promise | IFritzCommandResult | unknown; + destroy?(): Promise | void; +} + +export type TFritzSnapshotProvider = () => Promise | IFritzSnapshot | undefined; +export type TFritzCommandExecutor = ( + commandArg: IFritzCommand +) => Promise | IFritzCommandResult | unknown; diff --git a/ts/integrations/fritz/index.ts b/ts/integrations/fritz/index.ts index a60f3ce..0c1628a 100644 --- a/ts/integrations/fritz/index.ts +++ b/ts/integrations/fritz/index.ts @@ -1,2 +1,6 @@ +export * from './fritz.classes.client.js'; +export * from './fritz.classes.configflow.js'; export * from './fritz.classes.integration.js'; +export * from './fritz.discovery.js'; +export * from './fritz.mapper.js'; export * from './fritz.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index e04fa88..35406b7 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -134,7 +134,6 @@ import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js'; import { HomeAssistantBondIntegration } from '../bond/index.js'; import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js'; -import { HomeAssistantBoschShcIntegration } from '../bosch_shc/index.js'; import { HomeAssistantBrandsIntegration } from '../brands/index.js'; import { HomeAssistantBrandtIntegration } from '../brandt/index.js'; import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js'; @@ -232,7 +231,6 @@ import { HomeAssistantDeviceAutomationIntegration } from '../device_automation/i import { HomeAssistantDeviceSunLightTriggerIntegration } from '../device_sun_light_trigger/index.js'; import { HomeAssistantDeviceTrackerIntegration } from '../device_tracker/index.js'; import { HomeAssistantDevoloHomeControlIntegration } from '../devolo_home_control/index.js'; -import { HomeAssistantDevoloHomeNetworkIntegration } from '../devolo_home_network/index.js'; import { HomeAssistantDexcomIntegration } from '../dexcom/index.js'; import { HomeAssistantDhcpIntegration } from '../dhcp/index.js'; import { HomeAssistantDiagnosticsIntegration } from '../diagnostics/index.js'; @@ -387,7 +385,6 @@ import { HomeAssistantFreednsIntegration } from '../freedns/index.js'; import { HomeAssistantFreedomproIntegration } from '../freedompro/index.js'; import { HomeAssistantFreshrIntegration } from '../freshr/index.js'; import { HomeAssistantFressnapfTrackerIntegration } from '../fressnapf_tracker/index.js'; -import { HomeAssistantFritzIntegration } from '../fritz/index.js'; import { HomeAssistantFritzboxIntegration } from '../fritzbox/index.js'; import { HomeAssistantFritzboxCallmonitorIntegration } from '../fritzbox_callmonitor/index.js'; import { HomeAssistantFroniusIntegration } from '../fronius/index.js'; @@ -425,7 +422,6 @@ import { HomeAssistantGiosIntegration } from '../gios/index.js'; import { HomeAssistantGithubIntegration } from '../github/index.js'; import { HomeAssistantGitlabCiIntegration } from '../gitlab_ci/index.js'; import { HomeAssistantGitterIntegration } from '../gitter/index.js'; -import { HomeAssistantGlancesIntegration } from '../glances/index.js'; import { HomeAssistantGo2rtcIntegration } from '../go2rtc/index.js'; import { HomeAssistantGoalzeroIntegration } from '../goalzero/index.js'; import { HomeAssistantGogogate2Integration } from '../gogogate2/index.js'; @@ -477,7 +473,6 @@ import { HomeAssistantHeatmiserIntegration } from '../heatmiser/index.js'; import { HomeAssistantHegelIntegration } from '../hegel/index.js'; import { HomeAssistantHeickoIntegration } from '../heicko/index.js'; import { HomeAssistantHeiwaIntegration } from '../heiwa/index.js'; -import { HomeAssistantHeosIntegration } from '../heos/index.js'; import { HomeAssistantHereTravelTimeIntegration } from '../here_travel_time/index.js'; import { HomeAssistantHexaomIntegration } from '../hexaom/index.js'; import { HomeAssistantHiKumoIntegration } from '../hi_kumo/index.js'; @@ -576,7 +571,6 @@ import { HomeAssistantIotawattIntegration } from '../iotawatt/index.js'; import { HomeAssistantIottyIntegration } from '../iotty/index.js'; import { HomeAssistantIperf3Integration } from '../iperf3/index.js'; import { HomeAssistantIpmaIntegration } from '../ipma/index.js'; -import { HomeAssistantIppIntegration } from '../ipp/index.js'; import { HomeAssistantIqviaIntegration } from '../iqvia/index.js'; import { HomeAssistantIrishRailTransportIntegration } from '../irish_rail_transport/index.js'; import { HomeAssistantIrmKmiIntegration } from '../irm_kmi/index.js'; @@ -1542,7 +1536,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIn generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschShcIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration()); @@ -1640,7 +1633,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationInt generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceSunLightTriggerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceTrackerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevoloHomeControlIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevoloHomeNetworkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDexcomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDhcpIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiagnosticsIntegration()); @@ -1795,7 +1787,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreednsIntegration( generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreedomproIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreshrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFressnapfTrackerIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxCallmonitorIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration()); @@ -1833,7 +1824,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantGiosIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGithubIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitlabCiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitterIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantGlancesIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGo2rtcIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoalzeroIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGogogate2Integration()); @@ -1885,7 +1875,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeatmiserIntegratio generatedHomeAssistantPortIntegrations.push(new HomeAssistantHegelIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeickoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeiwaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeosIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHereTravelTimeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHexaomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHiKumoIntegration()); @@ -1984,7 +1973,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantIotawattIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantIottyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIperf3Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIpmaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantIppIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIqviaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrishRailTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrmKmiIntegration()); @@ -2816,7 +2804,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1406; +export const generatedHomeAssistantPortCount = 1400; export const handwrittenHomeAssistantPortDomains = [ "adguard", "airgradient", @@ -2830,17 +2818,23 @@ export const handwrittenHomeAssistantPortDomains = [ "axis", "blebox", "bluetooth_le_tracker", + "bosch_shc", "braviatv", "broadlink", "cast", "deconz", "denonavr", + "devolo_home_network", "dlna_dmr", "dsmr", "esphome", + "fritz", + "glances", + "heos", "homekit_controller", "homematic", "hue", + "ipp", "jellyfin", "knx", "kodi", diff --git a/ts/integrations/glances/.generated-by-smarthome-exchange b/ts/integrations/glances/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/glances/.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/glances/glances.classes.client.ts b/ts/integrations/glances/glances.classes.client.ts new file mode 100644 index 0000000..a4231ba --- /dev/null +++ b/ts/integrations/glances/glances.classes.client.ts @@ -0,0 +1,176 @@ +import { GlancesMapper } from './glances.mapper.js'; +import type { IGlancesConfig, IGlancesRawData, IGlancesRefreshResult, IGlancesSnapshot, TGlancesApiVersion } from './glances.types.js'; +import { glancesDefaultPort, glancesDefaultTimeoutMs } from './glances.types.js'; + +export class GlancesApiError extends Error {} +export class GlancesApiConnectionError extends GlancesApiError {} +export class GlancesApiAuthorizationError extends GlancesApiError {} +export class GlancesApiNoDataAvailableError extends GlancesApiError {} + +export class GlancesClient { + private currentSnapshot?: IGlancesSnapshot; + + constructor(private readonly config: IGlancesConfig) {} + + public async getSnapshot(): Promise { + if (this.hasManualData()) { + this.currentSnapshot = GlancesMapper.toSnapshot({ config: this.config, source: this.config.snapshot ? 'snapshot' : 'manual', online: this.config.online ?? true }); + return this.cloneSnapshot(this.currentSnapshot); + } + + if (this.config.host) { + try { + this.currentSnapshot = await this.fetchSnapshot(); + } catch (errorArg) { + this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg)); + } + return this.cloneSnapshot(this.currentSnapshot); + } + + this.currentSnapshot = this.offlineSnapshot('No Glances HTTP endpoint or snapshot data is configured.'); + return this.cloneSnapshot(this.currentSnapshot); + } + + public async refresh(): Promise { + if (this.hasManualData()) { + const snapshot = await this.getSnapshot(); + return { success: true, snapshot, data: { source: snapshot.source } }; + } + + if (!this.config.host) { + const snapshot = await this.getSnapshot(); + return { + success: false, + snapshot, + error: 'Glances refresh requires a configured HTTP endpoint or snapshot/manual data.', + }; + } + + try { + const snapshot = await this.fetchSnapshot(); + this.currentSnapshot = snapshot; + return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'http', apiVersion: snapshot.apiVersion } }; + } catch (errorArg) { + const error = this.errorMessage(errorArg); + const snapshot = this.offlineSnapshot(error); + this.currentSnapshot = snapshot; + return { success: false, snapshot: this.cloneSnapshot(snapshot), error }; + } + } + + public async ping(): Promise { + if (this.hasManualData()) { + return true; + } + if (!this.config.host) { + return false; + } + return (await this.refresh()).success; + } + + public async destroy(): Promise {} + + public async fetchSnapshot(): Promise { + const versions = this.apiVersions(); + let lastNoDataError: Error | undefined; + for (const version of versions) { + try { + const rawData = await this.requestRawData(version); + return GlancesMapper.toSnapshot({ + config: this.config, + rawData, + online: true, + source: 'http', + apiVersion: version, + }); + } catch (errorArg) { + if (errorArg instanceof GlancesApiNoDataAvailableError && versions.length > 1) { + lastNoDataError = errorArg; + continue; + } + throw errorArg; + } + } + throw lastNoDataError || new GlancesApiNoDataAvailableError('Could not connect to Glances API version 3 or 4.'); + } + + private async requestRawData(versionArg: TGlancesApiVersion): Promise { + if (this.config.password && !this.config.username) { + throw new GlancesApiAuthorizationError('Glances username and password must both be provided for HTTP basic authentication.'); + } + const url = `${this.baseUrl(versionArg)}/all`; + let response: Response; + try { + response = await globalThis.fetch(url, { + method: 'GET', + headers: this.headers(), + signal: AbortSignal.timeout(this.config.timeoutMs || glancesDefaultTimeoutMs), + }); + } catch (errorArg) { + throw new GlancesApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`); + } + + if (response.status === 401) { + throw new GlancesApiAuthorizationError('Please check your Glances credentials.'); + } + if (!response.ok) { + throw new GlancesApiNoDataAvailableError(`Glances endpoint /api/${versionArg}/all is not valid: HTTP ${response.status}.`); + } + + const text = await response.text(); + try { + const parsed = text ? JSON.parse(text) as unknown : {}; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('response is not a JSON object'); + } + return parsed as IGlancesRawData; + } catch (errorArg) { + throw new GlancesApiConnectionError(`Unable to parse Glances data from ${url}: ${this.errorMessage(errorArg)}`); + } + } + + private headers(): Record { + const headers: Record = { accept: 'application/json' }; + if (this.config.username && this.config.password) { + headers.authorization = `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`; + } + return headers; + } + + private baseUrl(versionArg: TGlancesApiVersion): string { + const protocol = this.config.ssl ? 'https' : 'http'; + const host = this.config.host || 'localhost'; + const port = this.config.port || glancesDefaultPort; + return `${protocol}://${this.hostForUrl(host)}:${port}/api/${versionArg}`; + } + + private hostForUrl(hostArg: string): string { + return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg; + } + + private apiVersions(): TGlancesApiVersion[] { + const configuredVersion = this.config.apiVersion || this.config.version; + return configuredVersion ? [configuredVersion] : [4, 3]; + } + + private offlineSnapshot(errorArg: string): IGlancesSnapshot { + return GlancesMapper.toSnapshot({ + config: this.config, + online: false, + source: 'runtime', + error: errorArg, + }); + } + + private hasManualData(): boolean { + return Boolean(this.config.snapshot || this.config.rawData || this.config.allData || this.config.haSensorData || this.config.sensorData); + } + + private errorMessage(errorArg: unknown): string { + return errorArg instanceof Error ? errorArg.message : String(errorArg); + } + + private cloneSnapshot(snapshotArg: IGlancesSnapshot): IGlancesSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IGlancesSnapshot; + } +} diff --git a/ts/integrations/glances/glances.classes.configflow.ts b/ts/integrations/glances/glances.classes.configflow.ts new file mode 100644 index 0000000..43907bb --- /dev/null +++ b/ts/integrations/glances/glances.classes.configflow.ts @@ -0,0 +1,133 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IGlancesConfig, IGlancesHaSensorData, IGlancesRawData, IGlancesSnapshot, TGlancesApiVersion } from './glances.types.js'; +import { glancesDefaultPort, glancesDefaultTimeoutMs } from './glances.types.js'; + +export class GlancesConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Glances', + description: 'Configure a local Glances REST API endpoint, or use snapshot/manual data from the discovery candidate.', + fields: [ + { name: 'host', label: 'Host', type: 'text' }, + { name: 'port', label: 'HTTP port', type: 'number' }, + { name: 'ssl', label: 'Use HTTPS', type: 'boolean' }, + { name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + { name: 'name', label: 'Name', type: 'text' }, + { name: 'apiVersion', label: 'API version', type: 'select', options: [ + { label: 'Auto (v4 then v3)', value: 'auto' }, + { label: 'v4', value: '4' }, + { label: 'v3', value: '3' }, + ] }, + ], + submit: async (valuesArg) => this.submit(candidateArg, valuesArg), + }; + } + + private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record): Promise> { + const metadata = candidateArg.metadata || {}; + const host = this.stringValue(valuesArg.host) || candidateArg.host; + const port = this.numberValue(valuesArg.port) || candidateArg.port || glancesDefaultPort; + const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(metadata.ssl) ?? false; + const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? false; + const username = this.stringValue(valuesArg.username) || this.stringValue(metadata.username); + const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password); + const apiVersion = this.apiVersionValue(valuesArg.apiVersion) || this.apiVersionValue(metadata.apiVersion); + const snapshot = this.snapshotValue(metadata.snapshot); + const rawData = this.rawDataValue(metadata.rawData || metadata.allData); + const haSensorData = this.sensorDataValue(metadata.haSensorData || metadata.sensorData); + + if (!host && !snapshot && !rawData && !haSensorData) { + return { kind: 'error', title: 'Glances setup failed', error: 'Glances host or snapshot/manual sensor data is required.' }; + } + if (!this.validPort(port)) { + return { kind: 'error', title: 'Glances setup failed', error: 'Glances port must be between 1 and 65535.' }; + } + if (password && !username) { + return { kind: 'error', title: 'Glances setup failed', error: 'Glances username is required when password is provided.' }; + } + + const config: IGlancesConfig = { + host, + port, + ssl, + verifySsl, + username, + password, + apiVersion, + name: this.stringValue(valuesArg.name) || candidateArg.name || this.stringValue(metadata.name), + uniqueId: candidateArg.id || (host ? `${host}:${port}` : undefined), + timeoutMs: glancesDefaultTimeoutMs, + snapshot, + rawData, + haSensorData, + }; + + return { + kind: 'done', + title: 'Glances configured', + config, + }; + } + + 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 Math.round(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? Math.round(parsed) : undefined; + } + return undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + if (valueArg.toLowerCase() === 'true') { + return true; + } + if (valueArg.toLowerCase() === 'false') { + return false; + } + } + return undefined; + } + + private apiVersionValue(valueArg: unknown): TGlancesApiVersion | undefined { + const value = typeof valueArg === 'number' ? String(valueArg) : this.stringValue(valueArg); + if (value === '3' || value === '4') { + return Number(value) as TGlancesApiVersion; + } + return undefined; + } + + private validPort(valueArg: number): boolean { + return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535; + } + + private snapshotValue(valueArg: unknown): IGlancesSnapshot | undefined { + return this.isRecord(valueArg) && 'host' in valueArg && 'sensorData' in valueArg ? valueArg as unknown as IGlancesSnapshot : undefined; + } + + private rawDataValue(valueArg: unknown): IGlancesRawData | undefined { + return this.isRecord(valueArg) ? valueArg as IGlancesRawData : undefined; + } + + private sensorDataValue(valueArg: unknown): IGlancesHaSensorData | undefined { + return this.isRecord(valueArg) ? valueArg as IGlancesHaSensorData : undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/glances/glances.classes.integration.ts b/ts/integrations/glances/glances.classes.integration.ts index c1613b0..5ccd196 100644 --- a/ts/integrations/glances/glances.classes.integration.ts +++ b/ts/integrations/glances/glances.classes.integration.ts @@ -1,26 +1,95 @@ -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 { GlancesClient } from './glances.classes.client.js'; +import { GlancesConfigFlow } from './glances.classes.configflow.js'; +import { createGlancesDiscoveryDescriptor } from './glances.discovery.js'; +import { GlancesMapper } from './glances.mapper.js'; +import type { IGlancesConfig } from './glances.types.js'; +import { glancesDomain } from './glances.types.js'; -export class HomeAssistantGlancesIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "glances", - displayName: "Glances", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/glances", - "upstreamDomain": "glances", - "integrationType": "service", - "iotClass": "local_polling", - "requirements": [ - "glances-api==0.10.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@engrbm87" - ] -}, - }); +export class GlancesIntegration extends BaseIntegration { + public readonly domain = glancesDomain; + public readonly displayName = 'Glances'; + public readonly status = 'read-only-runtime' as const; + public readonly discoveryDescriptor = createGlancesDiscoveryDescriptor(); + public readonly configFlow = new GlancesConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/glances', + upstreamDomain: glancesDomain, + integrationType: 'service', + iotClass: 'local_polling', + requirements: ['glances-api==0.10.0'], + dependencies: [] as string[], + afterDependencies: [] as string[], + codeowners: ['@engrbm87'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/glances', + discovery: { + manual: true, + http: 'Manual local HTTP endpoint candidates are recognized; no active LAN scan is performed.', + note: 'Glances has no HA automatic discovery. Configure host/port or provide raw /api/{version}/all data, HA sensor data, or a snapshot.', + }, + runtime: { + type: 'read-only-runtime', + polling: 'local HTTP Glances REST API /api/{version}/all', + services: ['snapshot', 'status', 'refresh'], + controls: false, + }, + localApi: { + implemented: [ + 'Glances REST API v4/v3 probing against /api/{version}/all', + 'native mapping compatible with glances-api get_ha_sensor_data for CPU, memory, disk, network, load, temperature, process, container, GPU, and RAID sensors', + 'manual raw API data, HA sensor data, and snapshot inputs', + 'read-only refresh service', + ], + explicitUnsupported: [ + 'Home Assistant Python glances_api compatibility wrapper', + 'write/control services', + 'fake live API success without a configured HTTP endpoint or snapshot/manual data', + ], + }, + }; + + public async setup(configArg: IGlancesConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new GlancesRuntime(new GlancesClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantGlancesIntegration extends GlancesIntegration {} + +class GlancesRuntime implements IIntegrationRuntime { + public domain = glancesDomain; + + constructor(private readonly client: GlancesClient) {} + + public async devices(): Promise { + return GlancesMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return GlancesMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain !== glancesDomain) { + return { success: false, error: `Unsupported Glances service domain: ${requestArg.domain}` }; + } + if (requestArg.service === 'snapshot' || requestArg.service === 'status') { + return { success: true, data: await this.client.getSnapshot() }; + } + if (requestArg.service === 'refresh' || requestArg.service === 'reload') { + const result = await this.client.refresh(); + return { success: result.success, error: result.error, data: result.snapshot || result.data }; + } + return { success: false, error: `Unsupported Glances service: ${requestArg.service}` }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/glances/glances.discovery.ts b/ts/integrations/glances/glances.discovery.ts new file mode 100644 index 0000000..8dece46 --- /dev/null +++ b/ts/integrations/glances/glances.discovery.ts @@ -0,0 +1,193 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IGlancesHttpCandidateRecord, IGlancesManualEntry, IGlancesSnapshot } from './glances.types.js'; +import { glancesDefaultPort, glancesDomain } from './glances.types.js'; + +export class GlancesManualMatcher implements IDiscoveryMatcher { + public id = 'glances-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Glances local HTTP and snapshot setup entries.'; + + public async matches(inputArg: IGlancesManualEntry): Promise { + const parsedUrl = parseUrl(inputArg.url); + const metadata = inputArg.metadata || {}; + const hasManualData = Boolean(inputArg.snapshot || inputArg.rawData || inputArg.allData || inputArg.haSensorData || inputArg.sensorData || metadata.snapshot || metadata.rawData || metadata.haSensorData || metadata.sensorData); + const text = [inputArg.name, inputArg.manufacturer, inputArg.model].filter(Boolean).join(' ').toLowerCase(); + const matched = Boolean(inputArg.host || parsedUrl || inputArg.port === glancesDefaultPort || metadata.glances || hasManualData || text.includes('glances') || text.includes('system monitor')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Glances setup hints.' }; + } + + const host = inputArg.host || parsedUrl?.host; + const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanMetadata(metadata.ssl) ?? false; + const port = inputArg.port || parsedUrl?.port || glancesDefaultPort; + const id = inputArg.id || this.snapshotId(inputArg.snapshot || metadata.snapshot) || (host ? `${host}:${port}` : undefined); + return { + matched: true, + confidence: host || hasManualData ? 'high' : 'medium', + reason: 'Manual entry can start Glances setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: glancesDomain, + id, + host, + port, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Glances', + model: inputArg.model || 'System Monitor', + metadata: { + ...metadata, + glances: true, + ssl, + verifySsl: inputArg.verifySsl ?? metadata.verifySsl, + username: inputArg.username ?? metadata.username, + password: inputArg.password ?? metadata.password, + apiVersion: inputArg.apiVersion || inputArg.version || metadata.apiVersion || parsedUrl?.apiVersion, + url: inputArg.url, + hasManualData, + snapshot: inputArg.snapshot || metadata.snapshot, + rawData: inputArg.rawData || inputArg.allData || metadata.rawData || metadata.allData, + haSensorData: inputArg.haSensorData || inputArg.sensorData || metadata.haSensorData || metadata.sensorData, + }, + }, + }; + } + + private snapshotId(valueArg: unknown): string | undefined { + const snapshot = isGlancesSnapshot(valueArg) ? valueArg : undefined; + return snapshot?.host.id || snapshot?.host.hostname || snapshot?.host.host; + } +} + +export class GlancesHttpMatcher implements IDiscoveryMatcher { + public id = 'glances-http-match'; + public source = 'http' as const; + public description = 'Recognize local HTTP candidates that point at a Glances REST API.'; + + public async matches(recordArg: IGlancesHttpCandidateRecord): Promise { + const url = recordArg.url || recordArg.location; + const parsedUrl = parseUrl(url); + const headers = normalizeKeys(recordArg.headers || {}); + const metadata = recordArg.metadata || {}; + const text = [url, recordArg.name, recordArg.manufacturer, recordArg.model, headers.server, headers['x-powered-by']].filter(Boolean).join(' ').toLowerCase(); + const matched = Boolean(parsedUrl?.apiVersion || parsedUrl?.port === glancesDefaultPort || recordArg.port === glancesDefaultPort || metadata.glances || text.includes('glances')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Glances API.' }; + } + const host = recordArg.host || parsedUrl?.host; + const ssl = recordArg.ssl ?? parsedUrl?.ssl ?? false; + const port = recordArg.port || parsedUrl?.port || glancesDefaultPort; + const id = host ? `${host}:${port}` : undefined; + return { + matched: true, + confidence: parsedUrl?.apiVersion && host ? 'high' : host ? 'medium' : 'low', + reason: 'HTTP candidate has Glances API hints.', + normalizedDeviceId: id, + candidate: { + source: 'http', + integrationDomain: glancesDomain, + id, + host, + port, + name: recordArg.name || 'Glances', + manufacturer: recordArg.manufacturer || 'Glances', + model: recordArg.model || 'System Monitor', + metadata: { + ...metadata, + glances: true, + ssl, + url, + apiVersion: parsedUrl?.apiVersion, + headers, + }, + }, + }; + } +} + +export class GlancesCandidateValidator implements IDiscoveryValidator { + public id = 'glances-candidate-validator'; + public description = 'Validate Glances candidates have a usable HTTP endpoint or snapshot/manual data.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter(Boolean).join(' ').toLowerCase(); + const matched = candidateArg.integrationDomain === glancesDomain + || Boolean(metadata.glances) + || candidateArg.port === glancesDefaultPort + || text.includes('glances') + || text.includes('system monitor'); + const hasManualData = Boolean(metadata.snapshot || metadata.rawData || metadata.allData || metadata.haSensorData || metadata.sensorData); + const port = candidateArg.port || glancesDefaultPort; + const hasUsableAddress = Boolean(candidateArg.host && isValidPort(port)); + + if (!matched || (!hasUsableAddress && !hasManualData)) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'Glances candidate lacks a usable host or snapshot/manual data.' : 'Candidate is not Glances.', + normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined), + }; + } + + const normalizedDeviceId = candidateArg.id || snapshotId(metadata.snapshot) || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined); + return { + matched: true, + confidence: normalizedDeviceId && hasUsableAddress ? 'certain' : hasUsableAddress ? 'high' : 'medium', + reason: 'Candidate has Glances metadata and a usable HTTP endpoint or snapshot/manual data.', + normalizedDeviceId, + candidate: { + ...candidateArg, + integrationDomain: glancesDomain, + port, + manufacturer: candidateArg.manufacturer || 'Glances', + model: candidateArg.model || 'System Monitor', + }, + }; + } +} + +export const createGlancesDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: glancesDomain, displayName: 'Glances' }) + .addMatcher(new GlancesManualMatcher()) + .addMatcher(new GlancesHttpMatcher()) + .addValidator(new GlancesCandidateValidator()); +}; + +const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; apiVersion?: 3 | 4 } | undefined => { + if (!valueArg) { + return undefined; + } + try { + const url = new URL(valueArg); + const versionMatch = url.pathname.match(/\/api\/(3|4)(?:\/|$)/); + return { + host: url.hostname, + port: url.port ? Number(url.port) : undefined, + ssl: url.protocol === 'https:', + apiVersion: versionMatch ? Number(versionMatch[1]) as 3 | 4 : undefined, + }; + } catch { + return undefined; + } +}; + +const normalizeKeys = (recordArg: Record): Record => { + const normalized: Record = {}; + for (const [key, value] of Object.entries(recordArg)) { + normalized[key.toLowerCase()] = value; + } + return normalized; +}; + +const booleanMetadata = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined; + +const isValidPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535; + +const isGlancesSnapshot = (valueArg: unknown): valueArg is IGlancesSnapshot => Boolean(valueArg && typeof valueArg === 'object' && 'host' in valueArg && 'sensorData' in valueArg); + +const snapshotId = (valueArg: unknown): string | undefined => { + const snapshot = isGlancesSnapshot(valueArg) ? valueArg : undefined; + return snapshot?.host.id || snapshot?.host.hostname || snapshot?.host.host; +}; diff --git a/ts/integrations/glances/glances.mapper.ts b/ts/integrations/glances/glances.mapper.ts new file mode 100644 index 0000000..f837d63 --- /dev/null +++ b/ts/integrations/glances/glances.mapper.ts @@ -0,0 +1,598 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { + IGlancesConfig, + IGlancesDiskIoSensorData, + IGlancesDiskSensorData, + IGlancesEnvironmentSensorData, + IGlancesGpuSensorData, + IGlancesHaSensorData, + IGlancesHostInfo, + IGlancesNetworkSensorData, + IGlancesRawData, + IGlancesSensorState, + IGlancesSnapshot, + TGlancesApiVersion, + TGlancesSensorCategory, + TGlancesSensorValue, + TGlancesSnapshotSource, +} from './glances.types.js'; +import { glancesDefaultPort, glancesDomain } from './glances.types.js'; + +interface IGlancesSnapshotOptions { + config: IGlancesConfig; + sensorData?: IGlancesHaSensorData; + rawData?: IGlancesRawData; + online?: boolean; + source?: TGlancesSnapshotSource; + apiVersion?: TGlancesApiVersion; + error?: string; +} + +interface IGlancesSensorDefinition { + key: string; + name: string; + category: TGlancesSensorCategory; + value: unknown; + unit?: string; + label?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + numeric?: boolean; + attributes?: Record; +} + +export class GlancesMapper { + public static toSnapshot(optionsArg: IGlancesSnapshotOptions): IGlancesSnapshot { + if (optionsArg.config.snapshot) { + return this.normalizeSnapshot(optionsArg.config.snapshot, optionsArg.config, optionsArg.source || 'snapshot'); + } + + const rawData = optionsArg.rawData || optionsArg.config.rawData || optionsArg.config.allData; + const apiVersion = optionsArg.apiVersion || optionsArg.config.apiVersion || optionsArg.config.version; + const sensorData = optionsArg.sensorData + || optionsArg.config.haSensorData + || optionsArg.config.sensorData + || (rawData ? this.haSensorDataFromRawData(rawData, apiVersion || 4) : {}); + const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData || Object.keys(sensorData).length); + const host = this.hostInfo(optionsArg.config, rawData, apiVersion); + const updatedAt = new Date().toISOString(); + const snapshot: IGlancesSnapshot = { + host, + sensorData: this.clone(sensorData), + sensors: this.sensorsFromHaSensorData(sensorData), + rawData: rawData ? this.clone(rawData) : undefined, + online, + updatedAt, + source: optionsArg.source || (rawData || Object.keys(sensorData).length ? 'manual' : 'runtime'), + apiVersion, + error: optionsArg.error, + }; + return this.normalizeSnapshot(snapshot, optionsArg.config, snapshot.source || 'runtime'); + } + + public static toDevices(snapshotArg: IGlancesSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return [{ + id: this.hostDeviceId(snapshotArg), + integrationDomain: glancesDomain, + name: this.hostName(snapshotArg), + protocol: snapshotArg.host.host ? 'http' : 'unknown', + manufacturer: 'Glances', + model: snapshotArg.host.osName || snapshotArg.host.platform || 'System Monitor', + online: snapshotArg.online, + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + ...snapshotArg.sensors.map((sensorArg) => ({ + id: sensorArg.key, + capability: 'sensor' as const, + name: sensorArg.name, + readable: true, + writable: false, + unit: sensorArg.unit, + })), + ], + state: [ + { featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt }, + ...snapshotArg.sensors.map((sensorArg) => ({ featureId: sensorArg.key, value: sensorArg.value, updatedAt })), + ], + metadata: this.cleanAttributes({ + host: snapshotArg.host.host, + port: snapshotArg.host.port, + ssl: snapshotArg.host.ssl, + apiVersion: snapshotArg.apiVersion || snapshotArg.host.apiVersion, + hostname: snapshotArg.host.hostname, + platform: snapshotArg.host.platform, + version: snapshotArg.host.version, + source: snapshotArg.source, + rawPlugins: snapshotArg.rawData ? Object.keys(snapshotArg.rawData).sort() : undefined, + error: snapshotArg.error, + }), + }]; + } + + public static toEntities(snapshotArg: IGlancesSnapshot): IIntegrationEntity[] { + const deviceId = this.hostDeviceId(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + const usedIds = new Map(); + return snapshotArg.sensors.map((sensorArg) => { + const id = this.entityId(this.hostName(snapshotArg), sensorArg, usedIds); + return { + id, + uniqueId: `${glancesDomain}_${uniqueBase}_${sensorArg.key}`, + integrationDomain: glancesDomain, + deviceId, + platform: 'sensor', + name: sensorArg.name, + state: sensorArg.value, + attributes: this.cleanAttributes({ + key: sensorArg.key, + category: sensorArg.category, + label: sensorArg.label, + unitOfMeasurement: sensorArg.unit, + deviceClass: sensorArg.deviceClass, + stateClass: sensorArg.stateClass, + entityCategory: sensorArg.entityCategory, + source: snapshotArg.source, + apiVersion: snapshotArg.apiVersion, + ...sensorArg.attributes, + }), + available: snapshotArg.online && sensorArg.available, + }; + }); + } + + public static haSensorDataFromRawData(rawDataArg: IGlancesRawData, apiVersionArg: TGlancesApiVersion): IGlancesHaSensorData { + const sensorData: IGlancesHaSensorData = {}; + + const fsData = this.arrayRecords(rawDataArg.fs); + if (fsData.length) { + sensorData.fs = {}; + for (const disk of fsData) { + const label = this.stringValue(disk.mnt_point) || this.stringValue(disk.mount_point) || this.stringValue(disk.device_name); + const used = this.numberValue(disk.used); + const size = this.numberValue(disk.size); + const percent = this.numberValue(disk.percent); + const free = this.numberValue(disk.free) ?? (size !== undefined && used !== undefined ? size - used : undefined); + if (!label) { + continue; + } + sensorData.fs[label] = this.cleanAttributes({ + disk_use: used !== undefined ? this.round(used / 1024 ** 3, 1) : undefined, + disk_use_percent: percent, + disk_size: size !== undefined ? this.round(size / 1024 ** 3, 1) : undefined, + disk_free: free !== undefined ? this.round(free / 1024 ** 3, 1) : undefined, + }) as IGlancesDiskSensorData; + } + } + + const rawSensors = this.arrayRecords(rawDataArg.sensors); + if (rawSensors.length) { + sensorData.sensors = {}; + for (const sensor of rawSensors) { + const label = this.stringValue(sensor.label); + const type = this.stringValue(sensor.type); + const value = this.numberValue(sensor.value); + if (!label || !type || value === undefined) { + continue; + } + sensorData.sensors[label] = { + ...(sensorData.sensors[label] || {}), + [type]: value, + }; + } + } + + const mem = this.recordValue(rawDataArg.mem); + if (mem) { + sensorData.mem = this.cleanAttributes({ + memory_use_percent: this.numberValue(mem.percent), + memory_use: this.bytesToMib(mem.used), + memory_free: this.bytesToMib(mem.free), + }); + } + + const memswap = this.recordValue(rawDataArg.memswap); + if (memswap) { + sensorData.memswap = this.cleanAttributes({ + swap_use_percent: this.numberValue(memswap.percent), + swap_use: this.bytesToGib(memswap.used), + swap_free: this.bytesToGib(memswap.free), + }); + } + + const load = this.recordValue(rawDataArg.load); + if (load) { + sensorData.load = this.cleanAttributes({ + processor_load: this.numberValue(load.min15), + processor_load_1m: this.numberValue(load.min1), + processor_load_5m: this.numberValue(load.min5), + }); + } + + const processcount = this.recordValue(rawDataArg.processcount); + if (processcount) { + sensorData.processcount = this.cleanAttributes({ + process_running: this.numberValue(processcount.running), + process_total: this.numberValue(processcount.total), + process_thread: this.numberValue(processcount.thread), + process_sleeping: this.numberValue(processcount.sleeping), + }); + } + + const quicklook = this.recordValue(rawDataArg.quicklook); + const cpu = this.numberValue(quicklook?.cpu); + if (cpu !== undefined) { + sensorData.cpu = { cpu_use_percent: cpu }; + } + + const percpu = this.arrayRecords(rawDataArg.percpu); + if (percpu.length) { + sensorData.percpu = {}; + for (const item of percpu) { + const cpuNumber = this.stringValue(item.cpu_number) || this.stringValue(item.key); + const total = this.numberValue(item.total); + if (cpuNumber && total !== undefined) { + sensorData.percpu[cpuNumber] = { cpu_use_percent: total }; + } + } + } + + const networks = this.arrayRecords(rawDataArg.network); + if (networks.length) { + sensorData.network = {}; + for (const network of networks) { + const label = this.stringValue(network.interface_name) || this.stringValue(network.name); + if (!label) { + continue; + } + let rx: number | undefined; + let tx: number | undefined; + if (apiVersionArg <= 3) { + const timeSinceUpdate = this.numberValue(network.time_since_update); + if (timeSinceUpdate && timeSinceUpdate > 0) { + const rxBytes = this.numberValue(network.rx); + const txBytes = this.numberValue(network.tx); + rx = rxBytes !== undefined ? Math.round(rxBytes / timeSinceUpdate) : undefined; + tx = txBytes !== undefined ? Math.round(txBytes / timeSinceUpdate) : undefined; + } + } else { + rx = this.numberValue(network.bytes_recv_rate_per_sec); + tx = this.numberValue(network.bytes_sent_rate_per_sec); + } + sensorData.network[label] = this.cleanAttributes({ + is_up: this.booleanValue(network.is_up), + rx, + tx, + speed: this.bytesToGib(network.speed), + }) as IGlancesNetworkSensorData; + } + } + + const containersData = apiVersionArg <= 3 + ? this.arrayRecords(this.recordValue(rawDataArg.dockers)?.containers || this.recordValue(rawDataArg.containers)?.containers) + : this.arrayRecords(rawDataArg.containers); + if (containersData.length) { + const activeContainers = containersData.filter((containerArg) => { + const status = this.stringValue(containerArg.status || containerArg.Status)?.toLowerCase(); + return status === 'running' || status === 'healthy'; + }); + sensorData.docker = { + docker_active: activeContainers.length, + docker_cpu_use: this.round(activeContainers.reduce((sumArg, containerArg) => sumArg + (this.numberValue(this.recordValue(containerArg.cpu)?.total) || 0), 0), 1), + docker_memory_use: this.round(activeContainers.reduce((sumArg, containerArg) => sumArg + (this.numberValue(this.recordValue(containerArg.memory)?.usage) || 0), 0) / 1024 ** 2, 1), + }; + sensorData.containers = {}; + for (const container of activeContainers) { + const name = this.stringValue(container.name); + if (!name) { + continue; + } + sensorData.containers[name] = { + container_cpu_use: this.round(this.numberValue(this.recordValue(container.cpu)?.total) || 0, 1), + container_memory_use: this.round((this.numberValue(this.recordValue(container.memory)?.usage) || 0) / 1024 ** 2, 1), + }; + } + } + + const raid = this.recordValue(rawDataArg.raid); + if (raid) { + sensorData.raid = raid as Record>; + } + if (rawDataArg.uptime !== undefined) { + sensorData.uptime = rawDataArg.uptime; + } + + const gpu = this.arrayRecords(rawDataArg.gpu); + if (gpu.length) { + sensorData.gpu = {}; + for (const item of gpu) { + const name = this.stringValue(item.name) || 'GPU'; + const gpuId = this.stringValue(item.gpu_id) || this.stringValue(item.id) || '0'; + sensorData.gpu[`${name} (GPU ${gpuId})`] = this.cleanAttributes({ + temperature: this.numberValue(item.temperature) || 0, + mem: this.numberValue(item.mem) || 0, + proc: this.numberValue(item.proc) || 0, + fan_speed: this.numberValue(item.fan_speed) || 0, + }) as IGlancesGpuSensorData; + } + } + + const diskio = this.arrayRecords(rawDataArg.diskio); + if (diskio.length) { + sensorData.diskio = {}; + for (const disk of diskio) { + const name = this.stringValue(disk.disk_name) || this.stringValue(disk.name); + const timeSinceUpdate = this.numberValue(disk.time_since_update); + if (!name || !timeSinceUpdate || timeSinceUpdate <= 0) { + continue; + } + const readBytes = this.numberValue(disk.read_bytes); + const writeBytes = this.numberValue(disk.write_bytes); + sensorData.diskio[name] = this.cleanAttributes({ + read: readBytes !== undefined ? Math.round(readBytes / timeSinceUpdate) : undefined, + write: writeBytes !== undefined ? Math.round(writeBytes / timeSinceUpdate) : undefined, + }) as IGlancesDiskIoSensorData; + } + } + + return sensorData; + } + + public static sensorsFromHaSensorData(sensorDataArg: IGlancesHaSensorData): IGlancesSensorState[] { + const sensors: IGlancesSensorState[] = []; + this.pushNumberSensor(sensors, { key: 'cpu_use_percent', name: 'CPU Usage', category: 'cpu', value: sensorDataArg.cpu?.cpu_use_percent, unit: '%', stateClass: 'measurement' }); + for (const [label, cpu] of Object.entries(sensorDataArg.percpu || {})) { + this.pushNumberSensor(sensors, { key: `cpu_${this.slug(label)}_use_percent`, name: `CPU ${label} Usage`, category: 'cpu', value: cpu.cpu_use_percent, unit: '%', label, stateClass: 'measurement', entityCategory: 'diagnostic' }); + } + + this.pushNumberSensor(sensors, { key: 'memory_use_percent', name: 'Memory Usage', category: 'memory', value: sensorDataArg.mem?.memory_use_percent, unit: '%', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'memory_use', name: 'Memory Use', category: 'memory', value: sensorDataArg.mem?.memory_use, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'memory_free', name: 'Memory Free', category: 'memory', value: sensorDataArg.mem?.memory_free, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'swap_use_percent', name: 'Swap Usage', category: 'memory', value: sensorDataArg.memswap?.swap_use_percent, unit: '%', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'swap_use', name: 'Swap Use', category: 'memory', value: sensorDataArg.memswap?.swap_use, unit: 'GiB', deviceClass: 'data_size', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'swap_free', name: 'Swap Free', category: 'memory', value: sensorDataArg.memswap?.swap_free, unit: 'GiB', deviceClass: 'data_size', stateClass: 'measurement' }); + + for (const [label, disk] of Object.entries(sensorDataArg.fs || {})) { + const labelSlug = this.slug(label); + this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_use_percent`, name: `${label} Disk Usage`, category: 'disk', value: disk.disk_use_percent, unit: '%', label, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_used`, name: `${label} Disk Used`, category: 'disk', value: disk.disk_use, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_size`, name: `${label} Disk Size`, category: 'disk', value: disk.disk_size, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_free`, name: `${label} Disk Free`, category: 'disk', value: disk.disk_free, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' }); + } + for (const [label, disk] of Object.entries(sensorDataArg.diskio || {})) { + const labelSlug = this.slug(label); + this.pushNumberSensor(sensors, { key: `diskio_${labelSlug}_read`, name: `${label} Disk Read`, category: 'disk', value: disk.read, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `diskio_${labelSlug}_write`, name: `${label} Disk Write`, category: 'disk', value: disk.write, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement' }); + } + + for (const [label, network] of Object.entries(sensorDataArg.network || {})) { + const labelSlug = this.slug(label); + const attributes = { isUp: network.is_up, speedGib: network.speed }; + this.pushNumberSensor(sensors, { key: `network_${labelSlug}_rx`, name: `${label} RX`, category: 'network', value: network.rx, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement', attributes }); + this.pushNumberSensor(sensors, { key: `network_${labelSlug}_tx`, name: `${label} TX`, category: 'network', value: network.tx, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement', attributes }); + } + + this.pushNumberSensor(sensors, { key: 'processor_load', name: 'CPU Load', category: 'load', value: sensorDataArg.load?.processor_load, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'processor_load_1m', name: 'CPU Load 1m', category: 'load', value: sensorDataArg.load?.processor_load_1m, stateClass: 'measurement', entityCategory: 'diagnostic' }); + this.pushNumberSensor(sensors, { key: 'processor_load_5m', name: 'CPU Load 5m', category: 'load', value: sensorDataArg.load?.processor_load_5m, stateClass: 'measurement', entityCategory: 'diagnostic' }); + + this.pushNumberSensor(sensors, { key: 'process_running', name: 'Running', category: 'process', value: sensorDataArg.processcount?.process_running, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'process_total', name: 'Total', category: 'process', value: sensorDataArg.processcount?.process_total, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'process_thread', name: 'Threads', category: 'process', value: sensorDataArg.processcount?.process_thread, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'process_sleeping', name: 'Sleeping', category: 'process', value: sensorDataArg.processcount?.process_sleeping, stateClass: 'measurement' }); + + for (const [label, environment] of Object.entries(sensorDataArg.sensors || {})) { + for (const [key, value] of Object.entries(environment)) { + const isTemperature = key.startsWith('temperature'); + const isFan = key === 'fan_speed'; + const name = isTemperature ? `${label} Temperature` : isFan ? `${label} Fan Speed` : `${label} ${this.titleFromKey(key)}`; + this.pushNumberSensor(sensors, { + key: `sensor_${this.slug(label)}_${this.slug(key)}`, + name, + category: isTemperature ? 'temperature' : 'other', + value, + unit: isTemperature ? '°C' : isFan ? 'rpm' : key === 'battery' ? '%' : undefined, + label, + deviceClass: isTemperature ? 'temperature' : key === 'battery' ? 'battery' : undefined, + stateClass: 'measurement', + attributes: { sensorType: key }, + }); + } + } + + this.pushNumberSensor(sensors, { key: 'docker_active', name: 'Containers Active', category: 'container', value: sensorDataArg.docker?.docker_active, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'docker_cpu_use', name: 'Containers CPU Usage', category: 'container', value: sensorDataArg.docker?.docker_cpu_use, unit: '%', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: 'docker_memory_use', name: 'Containers Memory Used', category: 'container', value: sensorDataArg.docker?.docker_memory_use, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' }); + for (const [label, container] of Object.entries(sensorDataArg.containers || {})) { + const labelSlug = this.slug(label); + this.pushNumberSensor(sensors, { key: `container_${labelSlug}_cpu_use`, name: `${label} Container CPU Usage`, category: 'container', value: container.container_cpu_use, unit: '%', label, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `container_${labelSlug}_memory_use`, name: `${label} Container Memory Used`, category: 'container', value: container.container_memory_use, unit: 'MiB', label, deviceClass: 'data_size', stateClass: 'measurement' }); + } + + for (const [label, gpu] of Object.entries(sensorDataArg.gpu || {})) { + const labelSlug = this.slug(label); + this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_temperature`, name: `${label} Temperature`, category: 'gpu', value: gpu.temperature, unit: '°C', label, deviceClass: 'temperature', stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_memory_usage`, name: `${label} Memory Usage`, category: 'gpu', value: gpu.mem, unit: '%', label, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_processor_usage`, name: `${label} Processor Usage`, category: 'gpu', value: gpu.proc, unit: '%', label, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_fan_speed`, name: `${label} Fan Speed`, category: 'gpu', value: gpu.fan_speed, unit: '%', label, stateClass: 'measurement' }); + } + + for (const [label, raid] of Object.entries(sensorDataArg.raid || {})) { + const labelSlug = this.slug(label); + this.pushNumberSensor(sensors, { key: `raid_${labelSlug}_available`, name: `${label} Available`, category: 'raid', value: raid.available, label, stateClass: 'measurement' }); + this.pushNumberSensor(sensors, { key: `raid_${labelSlug}_used`, name: `${label} Used`, category: 'raid', value: raid.used, label, stateClass: 'measurement' }); + } + + return sensors; + } + + public static hostDeviceId(snapshotArg: IGlancesSnapshot): string { + return `${glancesDomain}.host.${this.uniqueBase(snapshotArg)}`; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || glancesDomain; + } + + private static normalizeSnapshot(snapshotArg: IGlancesSnapshot, configArg: IGlancesConfig, sourceArg: TGlancesSnapshotSource): IGlancesSnapshot { + const rawData = snapshotArg.rawData ? this.clone(snapshotArg.rawData) : configArg.rawData || configArg.allData; + const apiVersion = snapshotArg.apiVersion || configArg.apiVersion || configArg.version; + const sensorData = Object.keys(snapshotArg.sensorData || {}).length + ? this.clone(snapshotArg.sensorData) + : rawData ? this.haSensorDataFromRawData(rawData, apiVersion || 4) : {}; + const host = { + ...this.hostInfo(configArg, rawData, apiVersion), + ...snapshotArg.host, + }; + host.port = host.port || (host.host ? configArg.port || glancesDefaultPort : configArg.port); + host.name = host.name || configArg.name || host.hostname || host.host || 'Glances'; + host.id = host.id || configArg.uniqueId || (host.host ? `${host.host}:${host.port || glancesDefaultPort}` : undefined) || host.hostname || host.name; + return { + ...snapshotArg, + host, + sensorData, + sensors: snapshotArg.sensors?.length ? this.clone(snapshotArg.sensors) : this.sensorsFromHaSensorData(sensorData), + rawData: rawData ? this.clone(rawData) : undefined, + online: snapshotArg.online, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + source: snapshotArg.source || sourceArg, + apiVersion, + }; + } + + private static hostInfo(configArg: IGlancesConfig, rawDataArg: IGlancesRawData | undefined, apiVersionArg: TGlancesApiVersion | undefined): IGlancesHostInfo { + const system = this.recordValue(rawDataArg?.system); + const host = configArg.host; + const port = configArg.port || (host ? glancesDefaultPort : undefined); + const hostname = this.stringValue(system?.hostname) || this.stringValue(system?.host); + return { + id: configArg.uniqueId || (host ? `${host}:${port}` : undefined) || hostname || configArg.name, + name: configArg.name || hostname || host || 'Glances', + host, + port, + ssl: configArg.ssl, + apiVersion: apiVersionArg, + hostname, + osName: this.stringValue(system?.os_name) || this.stringValue(system?.osName) || this.stringValue(system?.linux_distro), + platform: this.stringValue(system?.platform), + version: this.stringValue(system?.hr_name) || this.stringValue(system?.version), + }; + } + + private static pushNumberSensor(sensorsArg: IGlancesSensorState[], definitionArg: IGlancesSensorDefinition): void { + const value = this.numberValue(definitionArg.value); + if (value === undefined) { + return; + } + sensorsArg.push(this.sensor({ ...definitionArg, value, numeric: true })); + } + + private static sensor(definitionArg: IGlancesSensorDefinition): IGlancesSensorState { + const value = this.sensorValue(definitionArg.value); + return { + key: this.slug(definitionArg.key), + name: definitionArg.name, + category: definitionArg.category, + value, + unit: definitionArg.unit, + label: definitionArg.label, + deviceClass: definitionArg.deviceClass, + stateClass: definitionArg.stateClass, + entityCategory: definitionArg.entityCategory, + available: value !== null && (!definitionArg.numeric || typeof value === 'number'), + attributes: this.cleanAttributes(definitionArg.attributes || {}), + }; + } + + private static sensorValue(valueArg: unknown): TGlancesSensorValue { + if (typeof valueArg === 'number') { + return Number.isFinite(valueArg) ? valueArg : null; + } + if (typeof valueArg === 'string' || typeof valueArg === 'boolean') { + return valueArg; + } + return null; + } + + private static entityId(hostNameArg: string, sensorArg: IGlancesSensorState, usedIdsArg: Map): string { + const baseId = `sensor.${this.slug(`${hostNameArg} ${sensorArg.name}`)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return seen ? `${baseId}_${seen + 1}` : baseId; + } + + private static hostName(snapshotArg: IGlancesSnapshot): string { + return snapshotArg.host.name || snapshotArg.host.hostname || snapshotArg.host.host || 'Glances'; + } + + private static uniqueBase(snapshotArg: IGlancesSnapshot): string { + return this.slug(snapshotArg.host.id || snapshotArg.host.hostname || snapshotArg.host.host || this.hostName(snapshotArg)); + } + + private static bytesToMib(valueArg: unknown): number | undefined { + const value = this.numberValue(valueArg); + return value === undefined ? undefined : this.round(value / 1024 ** 2, 1); + } + + private static bytesToGib(valueArg: unknown): number | undefined { + const value = this.numberValue(valueArg); + return value === undefined ? undefined : this.round(value / 1024 ** 3, 1); + } + + private static round(valueArg: number, digitsArg: number): number { + const factor = 10 ** digitsArg; + return Math.round(valueArg * factor) / factor; + } + + private static titleFromKey(valueArg: string): string { + return valueArg.split('_').map((partArg) => partArg ? `${partArg[0].toUpperCase()}${partArg.slice(1)}` : '').join(' '); + } + + private static recordValue(valueArg: unknown): Record | undefined { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; + } + + private static arrayRecords(valueArg: unknown): Array> { + return Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is Record => Boolean(this.recordValue(itemArg))) : []; + } + + private static 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 static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + if (valueArg.toLowerCase() === 'true') { + return true; + } + if (valueArg.toLowerCase() === 'false') { + return false; + } + } + return undefined; + } + + private static cleanAttributes>(attributesArg: TRecord): TRecord { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as TRecord; + } + + private static clone(valueArg: TValue): TValue { + return JSON.parse(JSON.stringify(valueArg)) as TValue; + } +} diff --git a/ts/integrations/glances/glances.types.ts b/ts/integrations/glances/glances.types.ts index 707de15..d29cf96 100644 --- a/ts/integrations/glances/glances.types.ts +++ b/ts/integrations/glances/glances.types.ts @@ -1,4 +1,251 @@ -export interface IHomeAssistantGlancesConfig { - // TODO: replace with the TypeScript-native config for glances. +export const glancesDomain = 'glances'; +export const glancesDefaultHost = 'localhost'; +export const glancesDefaultPort = 61208; +export const glancesDefaultTimeoutMs = 10000; +export const glancesDefaultScanIntervalMs = 60000; + +export type TGlancesApiVersion = 3 | 4; +export type TGlancesSnapshotSource = 'http' | 'manual' | 'snapshot' | 'runtime'; +export type TGlancesSensorCategory = + | 'cpu' + | 'memory' + | 'disk' + | 'network' + | 'load' + | 'temperature' + | 'process' + | 'container' + | 'gpu' + | 'raid' + | 'system' + | 'other'; + +export type TGlancesSensorValue = string | number | boolean | null; + +export interface IGlancesConfig { + host?: string; + port?: number; + ssl?: boolean; + verifySsl?: boolean; + username?: string; + password?: string; + timeoutMs?: number; + name?: string; + uniqueId?: string; + apiVersion?: TGlancesApiVersion; + version?: TGlancesApiVersion; + snapshot?: IGlancesSnapshot; + rawData?: IGlancesRawData; + allData?: IGlancesRawData; + haSensorData?: IGlancesHaSensorData; + sensorData?: IGlancesHaSensorData; + online?: boolean; +} + +export interface IHomeAssistantGlancesConfig extends IGlancesConfig {} + +export interface IGlancesHostInfo { + id?: string; + name?: string; + host?: string; + port?: number; + ssl?: boolean; + apiVersion?: TGlancesApiVersion; + hostname?: string; + osName?: string; + platform?: string; + version?: string; +} + +export interface IGlancesSensorState { + key: string; + name: string; + category: TGlancesSensorCategory; + value: TGlancesSensorValue; + unit?: string; + label?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + available: boolean; + attributes?: Record; +} + +export interface IGlancesSnapshot { + host: IGlancesHostInfo; + sensorData: IGlancesHaSensorData; + sensors: IGlancesSensorState[]; + rawData?: IGlancesRawData; + online: boolean; + updatedAt?: string; + source?: TGlancesSnapshotSource; + apiVersion?: TGlancesApiVersion; + error?: string; +} + +export interface IGlancesRefreshResult { + success: boolean; + snapshot?: IGlancesSnapshot; + error?: string; + data?: Record; +} + +export interface IGlancesHaSensorData { + fs?: Record; + diskio?: Record; + mem?: IGlancesMemorySensorData; + memswap?: IGlancesSwapSensorData; + load?: IGlancesLoadSensorData; + processcount?: IGlancesProcessSensorData; + cpu?: IGlancesCpuSensorData; + percpu?: Record; + sensors?: Record; + network?: Record; + docker?: IGlancesDockerSensorData; + containers?: Record; + raid?: Record>; + uptime?: unknown; + gpu?: Record; + computed?: Record; [key: string]: unknown; } + +export interface IGlancesDiskSensorData { + disk_use?: number; + disk_use_percent?: number; + disk_size?: number; + disk_free?: number; + [key: string]: unknown; +} + +export interface IGlancesDiskIoSensorData { + read?: number; + write?: number; + [key: string]: unknown; +} + +export interface IGlancesMemorySensorData { + memory_use_percent?: number; + memory_use?: number; + memory_free?: number; + [key: string]: unknown; +} + +export interface IGlancesSwapSensorData { + swap_use_percent?: number; + swap_use?: number; + swap_free?: number; + [key: string]: unknown; +} + +export interface IGlancesLoadSensorData { + processor_load?: number; + processor_load_1m?: number; + processor_load_5m?: number; + [key: string]: unknown; +} + +export interface IGlancesProcessSensorData { + process_running?: number; + process_total?: number; + process_thread?: number; + process_sleeping?: number; + [key: string]: unknown; +} + +export interface IGlancesCpuSensorData { + cpu_use_percent?: number; + [key: string]: unknown; +} + +export interface IGlancesEnvironmentSensorData { + temperature_core?: number; + temperature_hdd?: number; + fan_speed?: number; + battery?: number; + [key: string]: unknown; +} + +export interface IGlancesNetworkSensorData { + is_up?: boolean; + rx?: number; + tx?: number; + speed?: number; + [key: string]: unknown; +} + +export interface IGlancesDockerSensorData { + docker_active?: number; + docker_cpu_use?: number; + docker_memory_use?: number; + [key: string]: unknown; +} + +export interface IGlancesContainerSensorData { + container_cpu_use?: number; + container_memory_use?: number; + [key: string]: unknown; +} + +export interface IGlancesGpuSensorData { + temperature?: number; + mem?: number; + proc?: number; + fan_speed?: number; + [key: string]: unknown; +} + +export interface IGlancesRawData { + system?: Record; + fs?: Array>; + diskio?: Array>; + sensors?: Array>; + mem?: Record; + memswap?: Record; + load?: Record; + processcount?: Record; + quicklook?: Record; + percpu?: Array>; + network?: Array>; + dockers?: Record; + containers?: unknown; + raid?: Record>; + uptime?: unknown; + gpu?: Array>; + [key: string]: unknown; +} + +export interface IGlancesManualEntry { + host?: string; + port?: number; + url?: string; + ssl?: boolean; + verifySsl?: boolean; + username?: string; + password?: string; + apiVersion?: TGlancesApiVersion; + version?: TGlancesApiVersion; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + snapshot?: IGlancesSnapshot; + rawData?: IGlancesRawData; + allData?: IGlancesRawData; + haSensorData?: IGlancesHaSensorData; + sensorData?: IGlancesHaSensorData; + metadata?: Record; +} + +export interface IGlancesHttpCandidateRecord { + url?: string; + location?: string; + host?: string; + port?: number; + ssl?: boolean; + name?: string; + manufacturer?: string; + model?: string; + headers?: Record; + metadata?: Record; +} diff --git a/ts/integrations/glances/index.ts b/ts/integrations/glances/index.ts index e56466c..a7eb979 100644 --- a/ts/integrations/glances/index.ts +++ b/ts/integrations/glances/index.ts @@ -1,2 +1,6 @@ export * from './glances.classes.integration.js'; +export * from './glances.classes.client.js'; +export * from './glances.classes.configflow.js'; +export * from './glances.discovery.js'; +export * from './glances.mapper.js'; export * from './glances.types.js'; diff --git a/ts/integrations/heos/.generated-by-smarthome-exchange b/ts/integrations/heos/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/heos/.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/heos/heos.classes.client.ts b/ts/integrations/heos/heos.classes.client.ts new file mode 100644 index 0000000..9bdb993 --- /dev/null +++ b/ts/integrations/heos/heos.classes.client.ts @@ -0,0 +1,735 @@ +import * as plugins from '../../plugins.js'; +import type { + IHeosCommandRequest, + IHeosConfig, + IHeosGroup, + IHeosHost, + IHeosMediaItem, + IHeosMessage, + IHeosMusicSource, + IHeosNowPlayingMedia, + IHeosPlayer, + IHeosSnapshot, + THeosPlayState, + THeosRepeatType, +} from './heos.types.js'; +import { heosDefaultPort } from './heos.types.js'; + +const defaultTimeoutMs = 15000; +const heosLineSeparator = '\r\n'; +const musicSourceAuxInput = 1027; +const musicSourceFavorites = 1028; + +const commandNames = { + checkAccount: 'system/check_account', + signIn: 'system/sign_in', + getPlayers: 'player/get_players', + getPlayState: 'player/get_play_state', + setPlayState: 'player/set_play_state', + getNowPlaying: 'player/get_now_playing_media', + getVolume: 'player/get_volume', + setVolume: 'player/set_volume', + volumeUp: 'player/volume_up', + volumeDown: 'player/volume_down', + getMute: 'player/get_mute', + setMute: 'player/set_mute', + getPlayMode: 'player/get_play_mode', + playNext: 'player/play_next', + playPrevious: 'player/play_previous', + getGroups: 'group/get_groups', + getGroupVolume: 'group/get_volume', + setGroupVolume: 'group/set_volume', + groupVolumeUp: 'group/volume_up', + groupVolumeDown: 'group/volume_down', + getGroupMute: 'group/get_mute', + setGroup: 'group/set_group', + getMusicSources: 'browse/get_music_sources', + browse: 'browse/browse', + playPreset: 'browse/play_preset', + playInput: 'browse/play_input', + playStream: 'browse/play_stream', +} as const; + +export class HeosCommandError extends Error { + constructor(public readonly command: string, messageArg: string, public readonly response?: IHeosMessage) { + super(`HEOS command ${command} failed: ${messageArg}`); + this.name = 'HeosCommandError'; + } +} + +export class HeosClient { + private currentSnapshot?: IHeosSnapshot; + + constructor(private readonly config: IHeosConfig) { + this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot)) : undefined; + } + + public async getSnapshot(): Promise { + if (this.currentSnapshot) { + return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot)); + } + if (!this.config.host) { + return this.normalizeSnapshot({ + system: { host: undefined, currentHost: undefined, isSignedIn: false }, + players: [], + groups: [], + musicSources: [], + favorites: {}, + inputSources: [], + lastUpdated: new Date().toISOString(), + }); + } + + const signedInUsername = await this.signInOrCheckAccount().catch(() => undefined); + const players = await this.getPlayers(); + const [groups, musicSources, inputSources, favorites] = await Promise.all([ + this.getGroups().catch(() => []), + this.getMusicSources().catch(() => []), + this.getInputSources().catch(() => []), + signedInUsername ? this.getFavorites().catch(() => ({})) : Promise.resolve({}), + ]); + + return this.normalizeSnapshot({ + system: { + host: this.config.host, + currentHost: this.config.host, + signedInUsername, + isSignedIn: Boolean(signedInUsername), + hosts: players.map((playerArg) => this.playerToHost(playerArg)), + preferredHosts: players.map((playerArg) => this.playerToHost(playerArg)).filter((hostArg) => hostArg.preferredHost), + }, + players, + groups, + musicSources, + inputSources, + favorites, + lastUpdated: new Date().toISOString(), + }); + } + + public async validateConnection(): Promise { + const players = await this.getPlayers(); + return this.normalizeSnapshot({ + system: { + host: this.config.host, + currentHost: this.config.host, + hosts: players.map((playerArg) => this.playerToHost(playerArg)), + }, + players, + lastUpdated: new Date().toISOString(), + }); + } + + public async execute(requestArg: IHeosCommandRequest): Promise { + if (requestArg.command === 'play') { + return this.setPlayState(this.requiredPlayerId(requestArg), 'play'); + } + if (requestArg.command === 'pause') { + return this.setPlayState(this.requiredPlayerId(requestArg), 'pause'); + } + if (requestArg.command === 'stop') { + return this.setPlayState(this.requiredPlayerId(requestArg), 'stop'); + } + if (requestArg.command === 'previous_track') { + return this.command(commandNames.playPrevious, { pid: this.requiredPlayerId(requestArg) }); + } + if (requestArg.command === 'next_track') { + return this.command(commandNames.playNext, { pid: this.requiredPlayerId(requestArg) }); + } + if (requestArg.command === 'volume_up') { + return this.command(commandNames.volumeUp, { pid: this.requiredPlayerId(requestArg), step: this.step(requestArg.step) }); + } + if (requestArg.command === 'volume_down') { + return this.command(commandNames.volumeDown, { pid: this.requiredPlayerId(requestArg), step: this.step(requestArg.step) }); + } + if (requestArg.command === 'set_volume') { + return this.command(commandNames.setVolume, { pid: this.requiredPlayerId(requestArg), level: this.volumePercent(requestArg) }); + } + if (requestArg.command === 'mute') { + if (typeof requestArg.muted !== 'boolean') { + throw new Error('HEOS mute command requires muted.'); + } + return this.command(commandNames.setMute, { pid: this.requiredPlayerId(requestArg), state: requestArg.muted ? 'on' : 'off' }); + } + if (requestArg.command === 'select_source') { + return this.selectSource(requestArg); + } + if (requestArg.command === 'play_preset') { + return this.command(commandNames.playPreset, { pid: this.requiredPlayerId(requestArg), preset: this.requiredNumber(requestArg.preset, 'HEOS play_preset requires preset.') }); + } + if (requestArg.command === 'play_input') { + if (!requestArg.source) { + throw new Error('HEOS play_input requires source.'); + } + return this.command(commandNames.playInput, { pid: this.requiredPlayerId(requestArg), input: requestArg.source }); + } + if (requestArg.command === 'play_media') { + if (!requestArg.url && !requestArg.mediaId) { + throw new Error('HEOS play_media requires url or mediaId.'); + } + return this.command(commandNames.playStream, { pid: this.requiredPlayerId(requestArg), url: requestArg.url || requestArg.mediaId || '' }); + } + if (requestArg.command === 'set_group' || requestArg.command === 'join') { + if (!requestArg.playerIds?.length) { + throw new Error('HEOS set_group requires playerIds.'); + } + return this.setGroup(requestArg.playerIds); + } + if (requestArg.command === 'unjoin') { + return this.unjoinPlayer(this.requiredPlayerId(requestArg)); + } + if (requestArg.command === 'group_volume_set') { + return this.command(commandNames.setGroupVolume, { gid: this.requiredGroupId(requestArg), level: this.volumePercent(requestArg) }); + } + if (requestArg.command === 'group_volume_up') { + return this.command(commandNames.groupVolumeUp, { gid: this.requiredGroupId(requestArg), step: this.step(requestArg.step) }); + } + if (requestArg.command === 'group_volume_down') { + return this.command(commandNames.groupVolumeDown, { gid: this.requiredGroupId(requestArg), step: this.step(requestArg.step) }); + } + throw new Error(`Unsupported HEOS command: ${requestArg.command}`); + } + + public async destroy(): Promise {} + + private async getPlayers(): Promise { + const response = await this.command(commandNames.getPlayers); + const players = this.payloadArray(response).map((itemArg) => this.playerFromData(itemArg)); + return Promise.all(players.map((playerArg) => this.refreshPlayer(playerArg))); + } + + private async refreshPlayer(playerArg: IHeosPlayer): Promise { + const [state, nowPlayingMedia, volume, muted, playMode] = await Promise.all([ + this.getPlayState(playerArg.playerId).catch(() => undefined), + this.getNowPlaying(playerArg.playerId).catch(() => undefined), + this.getVolume(playerArg.playerId).catch(() => undefined), + this.getMute(playerArg.playerId).catch(() => undefined), + this.getPlayMode(playerArg.playerId).catch(() => undefined), + ]); + return { + ...playerArg, + state: state || playerArg.state, + volume: volume ?? playerArg.volume, + muted: muted ?? playerArg.muted, + repeat: playMode?.repeat || playerArg.repeat, + shuffle: playMode?.shuffle ?? playerArg.shuffle, + nowPlayingMedia: nowPlayingMedia || playerArg.nowPlayingMedia, + available: true, + }; + } + + private async getGroups(): Promise { + const response = await this.command(commandNames.getGroups); + const groups = this.payloadArray(response).map((itemArg) => this.groupFromData(itemArg)); + return Promise.all(groups.map(async (groupArg) => ({ + ...groupArg, + volume: await this.getGroupVolume(groupArg.groupId).catch(() => groupArg.volume), + muted: await this.getGroupMute(groupArg.groupId).catch(() => groupArg.muted), + }))); + } + + private async getMusicSources(): Promise { + const response = await this.command(commandNames.getMusicSources); + return this.payloadArray(response).map((itemArg) => this.musicSourceFromData(itemArg)); + } + + private async getInputSources(): Promise { + const root = await this.browse(musicSourceAuxInput); + const inputs: IHeosMediaItem[] = []; + for (const item of root) { + if (!item.containerId && !item.browsable) { + continue; + } + const children = await this.browse(item.sourceId, item.containerId).catch(() => []); + inputs.push(...children); + } + return inputs; + } + + private async getFavorites(): Promise> { + const favorites = await this.browse(musicSourceFavorites); + const result: Record = {}; + favorites.forEach((favoriteArg, indexArg) => { + result[indexArg + 1] = favoriteArg; + }); + return result; + } + + private async browse(sourceIdArg: number, containerIdArg?: string): Promise { + const response = await this.command(commandNames.browse, containerIdArg ? { sid: sourceIdArg, cid: containerIdArg } : { sid: sourceIdArg }); + return this.payloadArray(response).map((itemArg) => this.mediaItemFromData(itemArg, sourceIdArg, containerIdArg)); + } + + private async signInOrCheckAccount(): Promise { + if (this.config.username && this.config.password) { + const response = await this.command(commandNames.signIn, { un: this.config.username, pw: this.config.password }); + return response.message.un; + } + const response = await this.command(commandNames.checkAccount); + return response.message.un; + } + + private async getPlayState(playerIdArg: number): Promise { + const response = await this.command(commandNames.getPlayState, { pid: playerIdArg }); + return response.message.state || 'unknown'; + } + + private async setPlayState(playerIdArg: number, stateArg: 'play' | 'pause' | 'stop'): Promise { + return this.command(commandNames.setPlayState, { pid: playerIdArg, state: stateArg }); + } + + private async getNowPlaying(playerIdArg: number): Promise { + const response = await this.command(commandNames.getNowPlaying, { pid: playerIdArg }); + const payload = this.payloadRecord(response); + return this.nowPlayingFromData({ ...payload, ...response.message }); + } + + private async getVolume(playerIdArg: number): Promise { + const response = await this.command(commandNames.getVolume, { pid: playerIdArg }); + return this.numberValue(response.message.level); + } + + private async getMute(playerIdArg: number): Promise { + const response = await this.command(commandNames.getMute, { pid: playerIdArg }); + return response.message.state === 'on'; + } + + private async getPlayMode(playerIdArg: number): Promise<{ repeat?: THeosRepeatType; shuffle?: boolean }> { + const response = await this.command(commandNames.getPlayMode, { pid: playerIdArg }); + return { repeat: response.message.repeat, shuffle: response.message.shuffle === 'on' }; + } + + private async getGroupVolume(groupIdArg: number): Promise { + const response = await this.command(commandNames.getGroupVolume, { gid: groupIdArg }); + return this.numberValue(response.message.level); + } + + private async getGroupMute(groupIdArg: number): Promise { + const response = await this.command(commandNames.getGroupMute, { gid: groupIdArg }); + return response.message.state === 'on'; + } + + private async selectSource(requestArg: IHeosCommandRequest): Promise { + if (!requestArg.source) { + throw new Error('HEOS select_source requires source.'); + } + const snapshot = await this.getSnapshot(); + const favoriteEntry = Object.entries(snapshot.favorites || {}).find(([, favoriteArg]) => favoriteArg.name === requestArg.source); + if (favoriteEntry) { + return this.command(commandNames.playPreset, { pid: this.requiredPlayerId(requestArg), preset: Number(favoriteEntry[0]) }); + } + const inputSource = (snapshot.inputSources || []).find((sourceArg) => sourceArg.name === requestArg.source || sourceArg.mediaId === requestArg.source); + if (inputSource) { + const params: Record = { pid: this.requiredPlayerId(requestArg), input: inputSource.mediaId || inputSource.name }; + if (inputSource.sourceId && inputSource.sourceId !== musicSourceAuxInput) { + params.spid = inputSource.sourceId; + } + return this.command(commandNames.playInput, params); + } + throw new Error(`Unknown HEOS source: ${requestArg.source}`); + } + + private async setGroup(playerIdsArg: number[]): Promise { + const uniquePlayerIds = [...new Set(playerIdsArg)]; + if (!uniquePlayerIds.length) { + throw new Error('HEOS set_group requires at least one player id.'); + } + return this.command(commandNames.setGroup, { pid: uniquePlayerIds.join(',') }); + } + + private async unjoinPlayer(playerIdArg: number): Promise { + const snapshot = await this.getSnapshot(); + const group = (snapshot.groups || []).find((groupArg) => groupArg.leadPlayerId === playerIdArg || groupArg.memberPlayerIds.includes(playerIdArg)); + if (!group) { + return undefined; + } + if (group.leadPlayerId === playerIdArg) { + return this.setGroup([playerIdArg]); + } + return this.setGroup([group.leadPlayerId, ...group.memberPlayerIds.filter((memberArg) => memberArg !== playerIdArg)]); + } + + private async command(commandArg: string, parametersArg: Record = {}): Promise { + const uri = this.commandUri(commandArg, parametersArg); + if (this.config.commandExecutor) { + const result = await this.config.commandExecutor.execute({ + command: commandArg, + parameters: parametersArg, + uri, + host: this.config.host, + port: this.config.port || heosDefaultPort, + }); + return this.executorResultToMessage(commandArg, result); + } + return this.requestTcp(commandArg, parametersArg, uri); + } + + private async requestTcp(commandArg: string, parametersArg: Record, uriArg: string): Promise { + const host = this.config.host; + if (!host) { + throw new Error('HEOS command transport requires config.host or commandExecutor.'); + } + const port = this.config.port || heosDefaultPort; + const timeoutMs = this.config.timeoutMs || defaultTimeoutMs; + + return new Promise((resolve, reject) => { + let buffer = ''; + let settled = false; + const socket = plugins.net.createConnection({ host, port }); + + const finish = (errorArg?: Error, messageArg?: IHeosMessage) => { + if (settled) { + return; + } + settled = true; + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + reject(errorArg); + return; + } + resolve(messageArg as IHeosMessage); + }; + + const handleLine = (lineArg: string) => { + if (!lineArg.trim()) { + return; + } + let message: IHeosMessage; + try { + message = this.parseRawMessage(lineArg); + } catch (errorArg) { + finish(errorArg instanceof Error ? errorArg : new Error(String(errorArg))); + return; + } + if (this.isUnderProcess(message) || message.command.startsWith('event/')) { + return; + } + if (message.command !== commandArg) { + return; + } + if (!message.result) { + finish(new HeosCommandError(commandArg, this.commandErrorText(message), message)); + return; + } + finish(undefined, message); + }; + + socket.setEncoding('utf8'); + socket.setTimeout(timeoutMs, () => finish(new Error(`HEOS TCP command ${commandArg} timed out after ${timeoutMs}ms.`))); + socket.on('connect', () => socket.write(`${uriArg}${heosLineSeparator}`)); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => finish(new Error(`HEOS TCP connection closed before ${commandArg} completed.`))); + socket.on('data', (chunkArg) => { + buffer += chunkArg; + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + for (const line of lines) { + handleLine(line); + } + }); + + void parametersArg; + }); + } + + private playerFromData(dataArg: Record): IHeosPlayer { + const version = this.stringValue(dataArg.version); + const playerId = this.requiredParsedNumber(dataArg.pid, 'HEOS player payload missing pid.'); + return { + name: this.stringValue(dataArg.name) || `HEOS ${playerId}`, + playerId, + model: this.stringValue(dataArg.model) || 'HEOS Player', + serial: this.stringValue(dataArg.serial), + version, + supportedVersion: this.isSupportedVersion(version), + ipAddress: this.stringValue(dataArg.ip), + network: this.stringValue(dataArg.network) || 'unknown', + lineOut: this.stringValue(dataArg.lineout) || this.numberValue(dataArg.lineout), + control: this.stringValue(dataArg.control) || this.numberValue(dataArg.control), + groupId: this.numberValue(dataArg.gid), + available: true, + }; + } + + private groupFromData(dataArg: Record): IHeosGroup { + const players = Array.isArray(dataArg.players) ? dataArg.players.filter((itemArg): itemArg is Record => this.isRecord(itemArg)) : []; + const leader = players.find((playerArg) => this.stringValue(playerArg.role) === 'leader'); + const members = players.filter((playerArg) => this.stringValue(playerArg.role) !== 'leader').map((playerArg) => this.numberValue(playerArg.pid)).filter((valueArg): valueArg is number => typeof valueArg === 'number'); + const groupId = this.requiredParsedNumber(dataArg.gid, 'HEOS group payload missing gid.'); + return { + name: this.stringValue(dataArg.name) || `HEOS Group ${groupId}`, + groupId, + leadPlayerId: this.numberValue(leader?.pid) || groupId, + memberPlayerIds: members, + }; + } + + private musicSourceFromData(dataArg: Record): IHeosMusicSource { + return { + sourceId: this.requiredParsedNumber(dataArg.sid, 'HEOS music source payload missing sid.'), + name: this.stringValue(dataArg.name) || 'HEOS Source', + type: this.stringValue(dataArg.type) || 'music_service', + imageUrl: this.stringValue(dataArg.image_url), + available: this.stringValue(dataArg.available) === 'true', + serviceUsername: this.stringValue(dataArg.service_username), + }; + } + + private mediaItemFromData(dataArg: Record, sourceIdArg?: number, containerIdArg?: string): IHeosMediaItem { + const sourceId = this.numberValue(dataArg.sid) || sourceIdArg; + if (!sourceId) { + throw new Error('HEOS media item payload missing sid.'); + } + return { + sourceId, + name: this.decodeName(this.stringValue(dataArg.name) || 'HEOS Media'), + type: this.stringValue(dataArg.type) || 'container', + imageUrl: this.stringValue(dataArg.image_url), + playable: this.stringValue(dataArg.playable) === 'yes', + browsable: dataArg.sid !== undefined || this.stringValue(dataArg.container) === 'yes', + containerId: this.stringValue(dataArg.cid) || containerIdArg, + mediaId: this.stringValue(dataArg.mid), + artist: this.stringValue(dataArg.artist), + album: this.stringValue(dataArg.album), + albumId: this.stringValue(dataArg.album_id), + }; + } + + private nowPlayingFromData(dataArg: Record): IHeosNowPlayingMedia { + const sourceId = this.numberValue(dataArg.sid); + const type = this.stringValue(dataArg.type); + return { + type, + song: this.stringValue(dataArg.song), + station: this.stringValue(dataArg.station), + album: this.stringValue(dataArg.album), + artist: this.stringValue(dataArg.artist), + imageUrl: this.stringValue(dataArg.image_url), + albumId: this.stringValue(dataArg.album_id), + mediaId: this.stringValue(dataArg.mid), + queueId: this.numberValue(dataArg.qid), + sourceId, + currentPosition: this.numberValue(dataArg.cur_pos), + duration: this.numberValue(dataArg.duration), + supportedControls: this.supportedControls(sourceId, type), + }; + } + + private playerToHost(playerArg: IHeosPlayer): IHeosHost { + return { + name: playerArg.name, + model: playerArg.model, + serial: playerArg.serial, + version: playerArg.version, + ipAddress: playerArg.ipAddress, + network: playerArg.network, + supportedVersion: playerArg.supportedVersion, + preferredHost: playerArg.network === 'wired' && playerArg.supportedVersion !== false && Boolean(playerArg.ipAddress), + }; + } + + private supportedControls(sourceIdArg: number | undefined, typeArg: string | undefined): string[] { + if (sourceIdArg === undefined) { + return []; + } + if (sourceIdArg === musicSourceAuxInput) { + return ['play', 'stop']; + } + if (typeArg === 'station') { + return ['play', 'pause', 'stop', 'play_next']; + } + return ['play', 'pause', 'stop', 'play_next', 'play_previous']; + } + + private normalizeSnapshot(snapshotArg: IHeosSnapshot): IHeosSnapshot { + const sourceList = snapshotArg.sourceList || [ + ...Object.values(snapshotArg.favorites || {}).map((favoriteArg) => favoriteArg.name), + ...(snapshotArg.inputSources || []).map((sourceArg) => sourceArg.name), + ].filter(Boolean); + return { + ...snapshotArg, + system: { + ...snapshotArg.system, + isSignedIn: snapshotArg.system.isSignedIn ?? Boolean(snapshotArg.system.signedInUsername), + }, + players: snapshotArg.players || [], + groups: snapshotArg.groups || [], + musicSources: snapshotArg.musicSources || [], + favorites: snapshotArg.favorites || {}, + inputSources: snapshotArg.inputSources || [], + sourceList: [...new Set(sourceList)], + lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(), + }; + } + + private commandUri(commandArg: string, parametersArg: Record): string { + const params = this.encodeQuery(parametersArg); + return params ? `heos://${commandArg}?${params}` : `heos://${commandArg}`; + } + + private encodeQuery(parametersArg: Record): string { + const pairs: string[] = []; + for (const key of Object.keys(parametersArg).sort()) { + const value = parametersArg[key]; + if (key === 'url') { + pairs.push(`${key}=${value}`); + } else { + pairs.unshift(`${key}=${this.quoteCliValue(value)}`); + } + } + return pairs.join('&'); + } + + private quoteCliValue(valueArg: string | number | boolean): string { + return String(valueArg).replace(/%/g, '%25').replace(/&/g, '%26').replace(/=/g, '%3D'); + } + + private parseRawMessage(rawArg: string): IHeosMessage { + const container = JSON.parse(rawArg) as { heos?: { command?: string; result?: string; message?: string }; payload?: unknown; options?: unknown }; + if (!container.heos?.command) { + throw new Error(`Invalid HEOS response: ${rawArg}`); + } + return { + command: container.heos.command, + result: (container.heos.result || 'success') === 'success', + message: this.parseMessage(container.heos.message || ''), + payload: container.payload, + options: container.options, + raw: rawArg, + }; + } + + private parseMessage(valueArg: string): Record { + const result: Record = {}; + const params = new URLSearchParams(valueArg); + params.forEach((value, key) => { + result[key] = value; + }); + if (!Object.keys(result).length && valueArg) { + result[valueArg] = ''; + } + return result; + } + + private executorResultToMessage(commandArg: string, resultArg: unknown): IHeosMessage { + if (this.isHeosMessage(resultArg)) { + if (!resultArg.result) { + throw new HeosCommandError(commandArg, this.commandErrorText(resultArg), resultArg); + } + return resultArg; + } + return { command: commandArg, result: true, message: {}, payload: resultArg }; + } + + private payloadArray(responseArg: IHeosMessage): Record[] { + return Array.isArray(responseArg.payload) ? responseArg.payload.filter((itemArg): itemArg is Record => this.isRecord(itemArg)) : []; + } + + private payloadRecord(responseArg: IHeosMessage): Record { + return this.isRecord(responseArg.payload) ? responseArg.payload : {}; + } + + private requiredPlayerId(requestArg: IHeosCommandRequest): number { + if (typeof requestArg.playerId === 'number') { + return requestArg.playerId; + } + throw new Error('HEOS command requires playerId.'); + } + + private requiredGroupId(requestArg: IHeosCommandRequest): number { + if (typeof requestArg.groupId === 'number') { + return requestArg.groupId; + } + throw new Error('HEOS group command requires groupId.'); + } + + private volumePercent(requestArg: IHeosCommandRequest): number { + const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined); + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error('HEOS volume command requires volumeLevel or volume.'); + } + return Math.max(0, Math.min(100, Math.round(value))); + } + + private step(valueArg: number | undefined): number { + const value = typeof valueArg === 'number' ? valueArg : 5; + return Math.max(1, Math.min(10, Math.round(value))); + } + + private requiredNumber(valueArg: unknown, errorArg: string): number { + if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) { + throw new Error(errorArg); + } + return valueArg; + } + + private requiredParsedNumber(valueArg: unknown, errorArg: string): number { + const value = this.numberValue(valueArg); + if (typeof value !== 'number') { + throw new Error(errorArg); + } + return value; + } + + 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 stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private isHeosMessage(valueArg: unknown): valueArg is IHeosMessage { + return this.isRecord(valueArg) && typeof valueArg.command === 'string' && typeof valueArg.result === 'boolean' && this.isRecord(valueArg.message); + } + + private isUnderProcess(messageArg: IHeosMessage): boolean { + return Object.keys(messageArg.message).some((keyArg) => keyArg.includes('command under process')); + } + + private commandErrorText(messageArg: IHeosMessage): string { + return messageArg.message.text || messageArg.message.error || messageArg.message.errno || 'Command failed'; + } + + private decodeName(valueArg: string): string { + try { + return decodeURIComponent(valueArg); + } catch { + return valueArg; + } + } + + private isSupportedVersion(versionArg: string | undefined): boolean | undefined { + if (!versionArg) { + return undefined; + } + const versionParts = versionArg.split('.').map((partArg) => Number(partArg)); + const targetParts = [3, 34, 0]; + for (let index = 0; index < targetParts.length; index++) { + const versionPart = versionParts[index] || 0; + if (versionPart > targetParts[index]) { + return true; + } + if (versionPart < targetParts[index]) { + return false; + } + } + return true; + } + + private cloneValue(valueArg: TValue): TValue { + return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue; + } +} diff --git a/ts/integrations/heos/heos.classes.configflow.ts b/ts/integrations/heos/heos.classes.configflow.ts new file mode 100644 index 0000000..2079d1a --- /dev/null +++ b/ts/integrations/heos/heos.classes.configflow.ts @@ -0,0 +1,58 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IHeosConfig } from './heos.types.js'; +import { heosDefaultPort } from './heos.types.js'; + +export class HeosConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Denon HEOS', + description: candidateArg.source === 'manual' + ? 'Configure a local HEOS host.' + : 'Confirm or adjust the discovered local HEOS host.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'username', label: 'HEOS Account Username', type: 'text' }, + { name: 'password', label: 'HEOS Account Password', type: 'password' }, + ], + submit: async (valuesArg) => { + const host = stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { kind: 'error', title: 'HEOS host required', error: 'HEOS setup requires a host.' }; + } + const port = numberValue(valuesArg.port) || candidateArg.port || heosDefaultPort; + return { + kind: 'done', + title: 'HEOS configured', + config: { + host, + port, + name: candidateArg.name, + manufacturer: candidateArg.manufacturer, + model: candidateArg.model, + serialNumber: candidateArg.serialNumber, + username: stringValue(valuesArg.username), + password: stringValue(valuesArg.password), + }, + }; + }, + }; + } +} + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; diff --git a/ts/integrations/heos/heos.classes.integration.ts b/ts/integrations/heos/heos.classes.integration.ts index cbdb5e1..a456aa6 100644 --- a/ts/integrations/heos/heos.classes.integration.ts +++ b/ts/integrations/heos/heos.classes.integration.ts @@ -1,27 +1,255 @@ -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 { HeosClient } from './heos.classes.client.js'; +import { HeosConfigFlow } from './heos.classes.configflow.js'; +import { createHeosDiscoveryDescriptor } from './heos.discovery.js'; +import { HeosMapper } from './heos.mapper.js'; +import type { IHeosCommandRequest, IHeosConfig, THeosCommand } from './heos.types.js'; -export class HomeAssistantHeosIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "heos", - displayName: "Denon HEOS", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/heos", - "upstreamDomain": "heos", - "integrationType": "hub", - "iotClass": "local_push", - "qualityScale": "platinum", - "requirements": [ - "pyheos==1.0.6" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@andrewsayre" - ] -}, - }); +export class HeosIntegration extends BaseIntegration { + public readonly domain = 'heos'; + public readonly displayName = 'Denon HEOS'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createHeosDiscoveryDescriptor(); + public readonly configFlow = new HeosConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/heos', + upstreamDomain: 'heos', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'platinum', + requirements: ['pyheos==1.0.6'], + dependencies: [], + afterDependencies: [], + codeowners: ['@andrewsayre'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/heos', + }; + + public async setup(configArg: IHeosConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new HeosRuntime(new HeosClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantHeosIntegration extends HeosIntegration {} + +class HeosRuntime implements IIntegrationRuntime { + public domain = 'heos'; + + constructor(private readonly client: HeosClient) {} + + public async devices(): Promise { + return HeosMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return HeosMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'media_player') { + return await this.callMediaPlayerService(requestArg); + } + if (requestArg.domain === 'heos') { + return await this.callHeosService(requestArg); + } + return { success: false, error: `Unsupported HEOS service domain: ${requestArg.domain}` }; + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'join' || requestArg.service === 'join_players') { + const playerIds = await this.joinPlayerIdsFromRequest(requestArg); + return { success: true, data: await this.client.execute({ command: 'set_group', playerIds }) }; + } + if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') { + return { success: true, data: await this.client.execute({ command: 'unjoin', playerId: await this.playerIdFromRequest(requestArg) }) }; + } + if (requestArg.service === 'group_volume_set') { + return { success: true, data: await this.client.execute({ command: 'group_volume_set', groupId: await this.groupIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level') }) }; + } + if (requestArg.service === 'group_volume_up') { + return { success: true, data: await this.client.execute({ command: 'group_volume_up', groupId: await this.groupIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) }; + } + if (requestArg.service === 'group_volume_down') { + return { success: true, data: await this.client.execute({ command: 'group_volume_down', groupId: await this.groupIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) }; + } + + const command = this.commandFromMediaService(requestArg.service); + if (!command) { + return { success: false, error: `Unsupported HEOS media_player service: ${requestArg.service}` }; + } + const result = await this.client.execute(await this.commandRequest(command, requestArg)); + return { success: true, data: result }; + } + + private async callHeosService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'group_volume_set' || requestArg.service === 'group_volume_up' || requestArg.service === 'group_volume_down') { + return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' }); + } + if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') { + return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' }); + } + return { success: false, error: `Unsupported HEOS service: ${requestArg.service}` }; + } + + private commandFromMediaService(serviceArg: string): THeosCommand | undefined { + if (serviceArg === 'media_play' || serviceArg === 'play') { + return 'play'; + } + if (serviceArg === 'media_pause' || serviceArg === 'pause') { + return 'pause'; + } + if (serviceArg === 'media_stop' || serviceArg === 'stop') { + return 'stop'; + } + if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track') { + return 'previous_track'; + } + if (serviceArg === 'media_next_track' || serviceArg === 'next_track') { + return 'next_track'; + } + if (serviceArg === 'volume_up') { + return 'volume_up'; + } + if (serviceArg === 'volume_down') { + return 'volume_down'; + } + if (serviceArg === 'volume_set' || serviceArg === 'set_volume') { + return 'set_volume'; + } + if (serviceArg === 'volume_mute' || serviceArg === 'mute') { + return 'mute'; + } + if (serviceArg === 'select_source' || serviceArg === 'select_input') { + return 'select_source'; + } + if (serviceArg === 'play_media') { + return 'play_media'; + } + return undefined; + } + + private async commandRequest(commandArg: THeosCommand, requestArg: IServiceCallRequest): Promise { + const base: IHeosCommandRequest = { + command: commandArg, + playerId: await this.playerIdFromRequest(requestArg), + step: this.numberData(requestArg, 'step'), + }; + if (commandArg === 'set_volume') { + base.volumeLevel = this.numberData(requestArg, 'volume_level'); + base.volume = this.numberData(requestArg, 'volume'); + } + if (commandArg === 'mute') { + base.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted'); + } + if (commandArg === 'select_source') { + base.source = this.stringData(requestArg, 'source') || this.stringData(requestArg, 'input'); + } + if (commandArg === 'play_media') { + base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'); + base.url = this.stringData(requestArg, 'url'); + base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType'); + } + return base; + } + + private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise { + const direct = this.numberData(requestArg, 'player_id') ?? this.numberData(requestArg, 'playerId') ?? this.numberData(requestArg, 'pid'); + if (typeof direct === 'number') { + return direct; + } + const snapshot = await this.client.getSnapshot(); + if (requestArg.target.entityId) { + const entityPlayerId = HeosMapper.entityPlayerId(snapshot, requestArg.target.entityId); + if (typeof entityPlayerId === 'number') { + return entityPlayerId; + } + } + if (requestArg.target.deviceId) { + const player = snapshot.players.find((playerArg) => HeosMapper.playerDeviceId(playerArg) === requestArg.target.deviceId); + if (player) { + return player.playerId; + } + } + if (snapshot.players.length === 1) { + return snapshot.players[0].playerId; + } + throw new Error('HEOS service call requires data.player_id or a target HEOS media_player entity.'); + } + + private async groupIdFromRequest(requestArg: IServiceCallRequest): Promise { + const direct = this.numberData(requestArg, 'group_id') ?? this.numberData(requestArg, 'groupId') ?? this.numberData(requestArg, 'gid'); + if (typeof direct === 'number') { + return direct; + } + const playerId = await this.playerIdFromRequest(requestArg); + const snapshot = await this.client.getSnapshot(); + const player = snapshot.players.find((playerArg) => playerArg.playerId === playerId); + if (typeof player?.groupId === 'number') { + return player.groupId; + } + throw new Error('HEOS group volume service requires a grouped player or data.group_id.'); + } + + private async joinPlayerIdsFromRequest(requestArg: IServiceCallRequest): Promise { + const leaderId = await this.playerIdFromRequest(requestArg); + const directIds = this.numberArrayData(requestArg, 'player_ids') || this.numberArrayData(requestArg, 'playerIds'); + if (directIds?.length) { + return [leaderId, ...directIds.filter((playerIdArg) => playerIdArg !== leaderId)]; + } + const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers'); + if (!members?.length) { + throw new Error('HEOS join service requires data.group_members or data.player_ids.'); + } + const snapshot = await this.client.getSnapshot(); + const memberIds = members.map((entityIdArg) => HeosMapper.entityPlayerId(snapshot, entityIdArg)).filter((playerIdArg): playerIdArg is number => typeof playerIdArg === 'number'); + if (memberIds.length !== members.length) { + throw new Error('HEOS join service could not resolve all group member entity IDs.'); + } + return [leaderId, ...memberIds.filter((playerIdArg) => playerIdArg !== leaderId)]; + } + + private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value ? value : undefined; + } + + private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'boolean' ? value : undefined; + } + + private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined { + const value = requestArg.data?.[keyArg]; + if (typeof value === 'string') { + return [value]; + } + return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined; + } + + private numberArrayData(requestArg: IServiceCallRequest, keyArg: string): number[] | undefined { + const value = requestArg.data?.[keyArg]; + if (typeof value === 'number' && Number.isFinite(value)) { + return [value]; + } + return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'number' && Number.isFinite(itemArg)) ? value : undefined; } } diff --git a/ts/integrations/heos/heos.discovery.ts b/ts/integrations/heos/heos.discovery.ts new file mode 100644 index 0000000..bbaa5d1 --- /dev/null +++ b/ts/integrations/heos/heos.discovery.ts @@ -0,0 +1,221 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IHeosManualEntry, IHeosMdnsRecord, IHeosSsdpRecord } from './heos.types.js'; +import { heosDefaultPort } from './heos.types.js'; + +const heosDomain = 'heos'; +const heosSsdpSt = 'urn:schemas-denon-com:device:ACT-Denon:1'; +const heosMdnsType = '_heos-audio._tcp.local.'; + +export class HeosSsdpMatcher implements IDiscoveryMatcher { + public id = 'heos-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Denon HEOS SSDP advertisements.'; + + public async matches(recordArg: IHeosSsdpRecord): Promise { + const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType'); + const usn = header(recordArg, 'usn'); + const location = header(recordArg, 'location'); + const manufacturer = upnp(recordArg, 'manufacturer'); + const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model'); + const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial'); + const friendlyName = upnp(recordArg, 'friendlyName'); + const haystack = `${st || ''} ${usn || ''} ${manufacturer || ''} ${model || ''} ${friendlyName || ''}`.toLowerCase(); + const matched = normalizeUrn(st) === normalizeUrn(heosSsdpSt) + || haystack.includes('act-denon') + || haystack.includes('heos'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not a HEOS advertisement.' }; + } + + const url = parseUrl(location); + const id = serialNumber || stripUuid(usn) || model; + return { + matched: true, + confidence: normalizeUrn(st) === normalizeUrn(heosSsdpSt) && url?.hostname ? 'certain' : url?.hostname ? 'high' : 'medium', + reason: 'SSDP record matches Denon HEOS metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: heosDomain, + id, + host: url?.hostname, + port: heosDefaultPort, + name: friendlyName, + manufacturer: normalizedManufacturer(manufacturer), + model, + serialNumber, + metadata: { st, usn, location, deviceType: upnp(recordArg, 'deviceType') }, + }, + }; + } +} + +export class HeosMdnsMatcher implements IDiscoveryMatcher { + public id = 'heos-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize HEOS zeroconf advertisements.'; + + public async matches(recordArg: IHeosMdnsRecord): Promise { + const type = recordArg.type || ''; + const txt = recordArg.txt || {}; + const model = valueForKey(txt, 'model') || valueForKey(txt, 'modelName'); + const serialNumber = valueForKey(txt, 'serial') || valueForKey(txt, 'serialNumber'); + const manufacturer = valueForKey(txt, 'manufacturer') || valueForKey(txt, 'brand'); + const haystack = `${recordArg.name || ''} ${type} ${manufacturer || ''} ${model || ''}`.toLowerCase(); + const matched = normalizeMdnsType(type) === normalizeMdnsType(heosMdnsType) || haystack.includes('heos'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a HEOS advertisement.' }; + } + + const id = serialNumber || valueForKey(txt, 'id') || recordArg.name; + return { + matched: true, + confidence: recordArg.host ? 'certain' : 'high', + reason: 'mDNS record matches HEOS zeroconf metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: heosDomain, + id, + host: recordArg.host, + port: heosDefaultPort, + name: recordArg.name, + manufacturer: normalizedManufacturer(manufacturer), + model, + serialNumber, + metadata: { mdnsName: recordArg.name, mdnsType: type, advertisedPort: recordArg.port, txt }, + }, + }; + } +} + +export class HeosManualMatcher implements IDiscoveryMatcher { + public id = 'heos-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual HEOS setup entries.'; + + public async matches(inputArg: IHeosManualEntry): Promise { + const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const matched = Boolean(inputArg.host || inputArg.metadata?.heos || haystack.includes('heos')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain HEOS setup hints.' }; + } + + const id = inputArg.serialNumber || inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || heosDefaultPort}` : undefined); + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start HEOS setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: heosDomain, + id, + host: inputArg.host, + port: inputArg.port || heosDefaultPort, + name: inputArg.name, + manufacturer: normalizedManufacturer(inputArg.manufacturer), + model: inputArg.model, + serialNumber: inputArg.serialNumber, + metadata: { ...inputArg.metadata, username: inputArg.username ? true : undefined, password: inputArg.password ? true : undefined }, + }, + }; + } +} + +export class HeosCandidateValidator implements IDiscoveryValidator { + public id = 'heos-candidate-validator'; + public description = 'Validate HEOS candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const matched = candidateArg.integrationDomain === heosDomain + || includesHeos(candidateArg.name) + || includesHeos(candidateArg.model) + || includesHeos(candidateArg.manufacturer) + || Boolean(metadata.heos) + || normalizeUrn(stringMetadata(metadata.st)) === normalizeUrn(heosSsdpSt) + || normalizeMdnsType(stringMetadata(metadata.mdnsType) || '') === normalizeMdnsType(heosMdnsType); + + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has HEOS metadata.' : 'Candidate is not HEOS.', + candidate: matched ? { ...candidateArg, port: candidateArg.port || heosDefaultPort } : undefined, + normalizedDeviceId: candidateArg.serialNumber || candidateArg.id, + }; + } +} + +export const createHeosDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: heosDomain, displayName: 'Denon HEOS' }) + .addMatcher(new HeosSsdpMatcher()) + .addMatcher(new HeosMdnsMatcher()) + .addMatcher(new HeosManualMatcher()) + .addValidator(new HeosCandidateValidator()); +}; + +const header = (recordArg: IHeosSsdpRecord, keyArg: string): string | undefined => { + return recordArg[keyArg as keyof IHeosSsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg); +}; + +const upnp = (recordArg: IHeosSsdpRecord, keyArg: string): string | undefined => { + return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg); +}; + +const valueForKey = (recordArg: Record | undefined, keyArg: string): string | undefined => { + if (!recordArg) { + return undefined; + } + const lowerKey = keyArg.toLowerCase(); + for (const [key, value] of Object.entries(recordArg)) { + if (key.toLowerCase() === lowerKey) { + return value; + } + } + return undefined; +}; + +const parseUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; + +const stripUuid = (valueArg: string | undefined): string | undefined => { + return valueArg?.replace(/^uuid:/i, '').split('::')[0]; +}; + +const normalizeUrn = (valueArg: string | undefined): string => { + return (valueArg || '').toLowerCase(); +}; + +const normalizeMdnsType = (valueArg: string): string => { + return valueArg.toLowerCase().replace(/\.$/, ''); +}; + +const normalizedManufacturer = (valueArg: string | undefined): string => { + if (!valueArg) { + return 'Denon'; + } + if (valueArg.toLowerCase().includes('marantz')) { + return 'Marantz'; + } + return valueArg.toLowerCase().includes('denon') ? 'Denon' : valueArg; +}; + +const includesHeos = (valueArg: string | undefined): boolean => { + return Boolean(valueArg?.toLowerCase().includes('heos')); +}; + +const stringMetadata = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' ? valueArg : undefined; +}; diff --git a/ts/integrations/heos/heos.mapper.ts b/ts/integrations/heos/heos.mapper.ts new file mode 100644 index 0000000..544dc07 --- /dev/null +++ b/ts/integrations/heos/heos.mapper.ts @@ -0,0 +1,322 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IHeosGroup, IHeosMediaItem, IHeosMusicSource, IHeosNowPlayingMedia, IHeosPlayer, IHeosSnapshot } from './heos.types.js'; + +const heosAuxInputSourceId = 1027; + +export class HeosMapper { + public static toDevices(snapshotArg: IHeosSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.lastUpdated || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: this.systemDeviceId(snapshotArg), + integrationDomain: 'heos', + name: this.systemName(snapshotArg), + protocol: 'unknown', + manufacturer: 'Denon', + model: 'HEOS System', + online: snapshotArg.players.some((playerArg) => playerArg.available !== false), + features: [ + { id: 'players', capability: 'sensor', name: 'Players', readable: true, writable: false }, + { id: 'groups', capability: 'sensor', name: 'Groups', readable: true, writable: false }, + { id: 'sources', capability: 'media', name: 'Sources', readable: true, writable: false }, + { id: 'signed_in', capability: 'sensor', name: 'Signed in', readable: true, writable: false }, + ], + state: [ + { featureId: 'players', value: snapshotArg.players.length, updatedAt }, + { featureId: 'groups', value: snapshotArg.groups?.length || 0, updatedAt }, + { featureId: 'sources', value: this.sourceList(snapshotArg).length, updatedAt }, + { featureId: 'signed_in', value: Boolean(snapshotArg.system.isSignedIn || snapshotArg.system.signedInUsername), updatedAt }, + ], + metadata: { + host: snapshotArg.system.currentHost || snapshotArg.system.host, + signedInUsername: snapshotArg.system.signedInUsername, + preferredHosts: snapshotArg.system.preferredHosts?.map((hostArg) => hostArg.ipAddress).filter(Boolean), + }, + }]; + + for (const player of snapshotArg.players) { + const media = player.nowPlayingMedia; + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true }, + { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' }, + { id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true }, + { id: 'source', capability: 'media', name: 'Source', readable: true, writable: true }, + { id: 'group', capability: 'media', name: 'Group', readable: true, writable: true }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'playback', value: this.mediaState(player), updatedAt }, + { featureId: 'volume', value: typeof player.volume === 'number' ? player.volume : null, updatedAt }, + { featureId: 'muted', value: typeof player.muted === 'boolean' ? player.muted : null, updatedAt }, + { featureId: 'source', value: this.currentSource(snapshotArg, media) || null, updatedAt }, + { featureId: 'group', value: player.groupId ?? null, updatedAt }, + ]; + if (media?.song || media?.station) { + features.push({ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false }); + state.push({ featureId: 'current_title', value: this.mediaTitle(media) || null, updatedAt }); + } + + devices.push({ + id: this.playerDeviceId(player), + integrationDomain: 'heos', + name: player.name, + protocol: 'unknown', + manufacturer: this.manufacturer(player), + model: this.model(player), + online: player.available !== false, + features, + state, + metadata: { + playerId: player.playerId, + serialNumber: player.serial, + softwareVersion: player.version, + ipAddress: player.ipAddress, + network: player.network, + groupId: player.groupId, + viaDeviceId: this.systemDeviceId(snapshotArg), + }, + }); + } + + return devices; + } + + public static toEntities(snapshotArg: IHeosSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const player of snapshotArg.players) { + const media = player.nowPlayingMedia; + const base = this.playerEntityBase(player); + entities.push({ + id: `media_player.${base}`, + uniqueId: `heos_${this.slug(String(player.playerId))}`, + integrationDomain: 'heos', + deviceId: this.playerDeviceId(player), + platform: 'media_player', + name: player.name, + state: this.mediaState(player), + attributes: { + deviceClass: 'speaker', + playerId: player.playerId, + model: player.model, + serialNumber: player.serial, + ipAddress: player.ipAddress, + volumeLevel: this.volumeLevel(player), + volume: player.volume, + isVolumeMuted: player.muted, + source: this.currentSource(snapshotArg, media), + sourceList: this.sourceList(snapshotArg), + groupId: player.groupId, + groupMembers: this.groupMembers(snapshotArg, player), + groupRole: this.groupRole(snapshotArg, player), + repeat: this.repeatMode(player.repeat), + shuffle: player.shuffle, + mediaContentType: 'music', + mediaContentId: media?.mediaId, + mediaTitle: this.mediaTitle(media), + mediaArtist: media?.artist, + mediaAlbumName: media?.album, + mediaDuration: this.seconds(media?.duration), + mediaPosition: media?.duration ? this.seconds(media.currentPosition) : undefined, + mediaImageUrl: media?.imageUrl || undefined, + mediaAlbumId: media?.albumId, + mediaQueueId: media?.queueId, + mediaSourceId: media?.sourceId, + mediaStation: media?.station, + mediaType: media?.type, + supportedControls: media?.supportedControls, + playbackError: player.playbackError, + }, + available: player.available !== false, + }); + + entities.push({ + id: `sensor.${base}_heos_media`, + uniqueId: `heos_${this.slug(String(player.playerId))}_media`, + integrationDomain: 'heos', + deviceId: this.playerDeviceId(player), + platform: 'sensor', + name: `${player.name} HEOS Media`, + state: this.mediaTitle(media) || media?.station || 'None', + attributes: { + playerId: player.playerId, + media, + source: this.currentSource(snapshotArg, media), + }, + available: player.available !== false, + }); + } + + entities.push({ + id: `sensor.${this.slug(this.systemName(snapshotArg))}_sources`, + uniqueId: `heos_${this.systemUniqueBase(snapshotArg)}_sources`, + integrationDomain: 'heos', + deviceId: this.systemDeviceId(snapshotArg), + platform: 'sensor', + name: `${this.systemName(snapshotArg)} Sources`, + state: this.sourceList(snapshotArg).length, + attributes: { + sourceList: this.sourceList(snapshotArg), + favorites: this.favoriteList(snapshotArg), + inputSources: snapshotArg.inputSources || [], + musicSources: snapshotArg.musicSources || [], + }, + available: snapshotArg.players.some((playerArg) => playerArg.available !== false), + }); + + entities.push({ + id: `sensor.${this.slug(this.systemName(snapshotArg))}_groups`, + uniqueId: `heos_${this.systemUniqueBase(snapshotArg)}_groups`, + integrationDomain: 'heos', + deviceId: this.systemDeviceId(snapshotArg), + platform: 'sensor', + name: `${this.systemName(snapshotArg)} Groups`, + state: snapshotArg.groups?.length || 0, + attributes: { + groups: (snapshotArg.groups || []).map((groupArg) => ({ + ...groupArg, + members: this.groupEntityIds(snapshotArg, groupArg), + })), + }, + available: snapshotArg.players.some((playerArg) => playerArg.available !== false), + }); + + return entities; + } + + public static entityPlayerId(snapshotArg: IHeosSnapshot, entityIdArg: string): number | undefined { + const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg); + const playerId = entity?.attributes?.playerId; + return typeof playerId === 'number' ? playerId : undefined; + } + + public static playerDeviceId(playerArg: IHeosPlayer): string { + return `heos.player.${this.slug(String(playerArg.playerId))}`; + } + + public static slug(valueArg: string | undefined): string { + return (valueArg || 'heos').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'heos'; + } + + private static mediaState(playerArg: IHeosPlayer): string { + if (playerArg.available === false) { + return 'off'; + } + const state = playerArg.state?.toLowerCase(); + if (state === 'play') { + return 'playing'; + } + if (state === 'pause') { + return 'paused'; + } + return 'idle'; + } + + private static currentSource(snapshotArg: IHeosSnapshot, mediaArg: IHeosNowPlayingMedia | undefined): string | undefined { + if (!mediaArg) { + return undefined; + } + if (mediaArg.sourceId === heosAuxInputSourceId) { + const byStation = snapshotArg.inputSources?.find((sourceArg) => sourceArg.name === mediaArg.station); + if (byStation) { + return byStation.name; + } + const byMediaId = snapshotArg.inputSources?.find((sourceArg) => sourceArg.mediaId === mediaArg.mediaId); + if (byMediaId) { + return byMediaId.name; + } + } + if (mediaArg.type === 'station') { + const favorite = this.favoriteList(snapshotArg).find((favoriteArg) => favoriteArg.name === mediaArg.station || favoriteArg.mediaId === mediaArg.albumId); + return favorite?.name; + } + return undefined; + } + + private static sourceList(snapshotArg: IHeosSnapshot): string[] { + if (snapshotArg.sourceList) { + return [...snapshotArg.sourceList]; + } + const names = [...this.favoriteList(snapshotArg).map((favoriteArg) => favoriteArg.name), ...(snapshotArg.inputSources || []).map((sourceArg) => sourceArg.name)]; + return [...new Set(names.filter(Boolean))]; + } + + private static favoriteList(snapshotArg: IHeosSnapshot): IHeosMediaItem[] { + return Object.entries(snapshotArg.favorites || {}) + .sort(([leftArg], [rightArg]) => Number(leftArg) - Number(rightArg)) + .map(([, favoriteArg]) => favoriteArg); + } + + private static groupMembers(snapshotArg: IHeosSnapshot, playerArg: IHeosPlayer): string[] | undefined { + const group = (snapshotArg.groups || []).find((groupArg) => groupArg.groupId === playerArg.groupId); + if (!group) { + return undefined; + } + return this.groupEntityIds(snapshotArg, group); + } + + private static groupEntityIds(snapshotArg: IHeosSnapshot, groupArg: IHeosGroup): string[] { + const playerIds = [groupArg.leadPlayerId, ...groupArg.memberPlayerIds]; + return playerIds.map((playerIdArg) => { + const player = snapshotArg.players.find((playerArg) => playerArg.playerId === playerIdArg); + return player ? `media_player.${this.playerEntityBase(player)}` : undefined; + }).filter((entityArg): entityArg is string => Boolean(entityArg)); + } + + private static groupRole(snapshotArg: IHeosSnapshot, playerArg: IHeosPlayer): string | undefined { + const group = (snapshotArg.groups || []).find((groupArg) => groupArg.groupId === playerArg.groupId); + if (!group) { + return undefined; + } + return group.leadPlayerId === playerArg.playerId ? 'leader' : 'member'; + } + + private static mediaTitle(mediaArg: IHeosNowPlayingMedia | undefined): string | undefined { + return mediaArg?.song || mediaArg?.station; + } + + private static repeatMode(repeatArg: string | undefined): 'off' | 'one' | 'all' | undefined { + if (!repeatArg) { + return undefined; + } + if (repeatArg === 'on_one') { + return 'one'; + } + if (repeatArg === 'on_all') { + return 'all'; + } + return 'off'; + } + + private static volumeLevel(playerArg: IHeosPlayer): number | undefined { + return typeof playerArg.volume === 'number' ? Math.max(0, Math.min(1, playerArg.volume / 100)) : undefined; + } + + private static seconds(valueArg: number | undefined): number | undefined { + return typeof valueArg === 'number' ? Math.floor(valueArg / 1000) : undefined; + } + + private static playerEntityBase(playerArg: IHeosPlayer): string { + return this.slug(playerArg.name || String(playerArg.playerId)); + } + + private static systemDeviceId(snapshotArg: IHeosSnapshot): string { + return `heos.system.${this.systemUniqueBase(snapshotArg)}`; + } + + private static systemUniqueBase(snapshotArg: IHeosSnapshot): string { + return this.slug(snapshotArg.system.host || snapshotArg.system.currentHost || snapshotArg.players[0]?.serial || 'heos'); + } + + private static systemName(snapshotArg: IHeosSnapshot): string { + return snapshotArg.system.host ? 'HEOS System' : snapshotArg.players[0]?.name ? `${snapshotArg.players[0].name} HEOS System` : 'HEOS System'; + } + + private static manufacturer(playerArg: IHeosPlayer): string { + const modelParts = playerArg.model.split(/\s+/, 2); + return modelParts.length === 2 ? modelParts[0] : 'HEOS'; + } + + private static model(playerArg: IHeosPlayer): string { + const modelParts = playerArg.model.split(/\s+/, 2); + return modelParts.length === 2 ? modelParts[1] : playerArg.model; + } +} diff --git a/ts/integrations/heos/heos.types.ts b/ts/integrations/heos/heos.types.ts index 9b830e5..66884fa 100644 --- a/ts/integrations/heos/heos.types.ts +++ b/ts/integrations/heos/heos.types.ts @@ -1,4 +1,230 @@ -export interface IHomeAssistantHeosConfig { - // TODO: replace with the TypeScript-native config for heos. - [key: string]: unknown; +export const heosDefaultPort = 1255; + +export type THeosPlayState = 'play' | 'pause' | 'stop' | 'unknown' | string; +export type THeosRepeatType = 'on_all' | 'on_one' | 'off' | string; +export type THeosMediaType = + | 'album' + | 'artist' + | 'container' + | 'dlna_server' + | 'genre' + | 'heos_server' + | 'heos_service' + | 'music_service' + | 'playlist' + | 'song' + | 'station' + | string; +export type THeosNetworkType = 'wired' | 'wifi' | 'unknown' | string; +export type THeosCommand = + | 'play' + | 'pause' + | 'stop' + | 'previous_track' + | 'next_track' + | 'volume_up' + | 'volume_down' + | 'set_volume' + | 'mute' + | 'select_source' + | 'play_media' + | 'play_preset' + | 'play_input' + | 'set_group' + | 'join' + | 'unjoin' + | 'group_volume_set' + | 'group_volume_up' + | 'group_volume_down'; + +export interface IHeosConfig { + host?: string; + port?: number; + name?: string; + username?: string; + password?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + timeoutMs?: number; + snapshot?: IHeosSnapshot; + commandExecutor?: IHeosCommandExecutor; +} + +export interface IHomeAssistantHeosConfig extends IHeosConfig {} + +export interface IHeosCommandExecutor { + execute(requestArg: IHeosRawCommandRequest): Promise; +} + +export interface IHeosRawCommandRequest { + command: string; + parameters: Record; + uri: string; + host?: string; + port: number; +} + +export interface IHeosCommandRequest { + command: THeosCommand; + playerId?: number; + groupId?: number; + playerIds?: number[]; + source?: string; + mediaId?: string; + mediaType?: string; + preset?: number; + url?: string; + volumeLevel?: number; + volume?: number; + muted?: boolean; + step?: number; +} + +export interface IHeosMessage { + command: string; + result: boolean; + message: Record; + payload?: unknown; + options?: unknown; + raw?: string; +} + +export interface IHeosSystemInfo { + host?: string; + currentHost?: string; + signedInUsername?: string; + isSignedIn?: boolean; + hosts?: IHeosHost[]; + preferredHosts?: IHeosHost[]; +} + +export interface IHeosHost { + name: string; + model: string; + serial?: string; + version?: string; + ipAddress?: string; + network?: THeosNetworkType; + supportedVersion?: boolean; + preferredHost?: boolean; +} + +export interface IHeosPlayer { + name: string; + playerId: number; + model: string; + serial?: string; + version?: string; + supportedVersion?: boolean; + ipAddress?: string; + network?: THeosNetworkType; + lineOut?: number | string; + control?: number | string; + state?: THeosPlayState; + volume?: number; + muted?: boolean; + repeat?: THeosRepeatType; + shuffle?: boolean; + playbackError?: string; + nowPlayingMedia?: IHeosNowPlayingMedia; + available?: boolean; + groupId?: number; +} + +export interface IHeosNowPlayingMedia { + type?: THeosMediaType; + song?: string; + station?: string; + album?: string; + artist?: string; + imageUrl?: string; + albumId?: string; + mediaId?: string; + queueId?: number; + sourceId?: number; + currentPosition?: number; + duration?: number; + supportedControls?: string[]; +} + +export interface IHeosGroup { + name: string; + groupId: number; + leadPlayerId: number; + memberPlayerIds: number[]; + volume?: number; + muted?: boolean; +} + +export interface IHeosMediaBase { + sourceId: number; + name: string; + type: THeosMediaType; + imageUrl?: string; +} + +export interface IHeosMusicSource extends IHeosMediaBase { + available?: boolean; + serviceUsername?: string; +} + +export interface IHeosMediaItem extends IHeosMediaBase { + playable?: boolean; + browsable?: boolean; + containerId?: string; + mediaId?: string; + artist?: string; + album?: string; + albumId?: string; +} + +export interface IHeosQueueItem { + queueId: number; + song?: string; + album?: string; + artist?: string; + imageUrl?: string; + mediaId?: string; + albumId?: string; +} + +export interface IHeosSnapshot { + system: IHeosSystemInfo; + players: IHeosPlayer[]; + groups?: IHeosGroup[]; + musicSources?: IHeosMusicSource[]; + favorites?: Record; + inputSources?: IHeosMediaItem[]; + sourceList?: string[]; + lastUpdated?: string; +} + +export interface IHeosSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; + upnp?: Record; +} + +export interface IHeosMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IHeosManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + username?: string; + password?: string; + metadata?: Record; } diff --git a/ts/integrations/heos/index.ts b/ts/integrations/heos/index.ts index 9b68fa1..4623a90 100644 --- a/ts/integrations/heos/index.ts +++ b/ts/integrations/heos/index.ts @@ -1,2 +1,6 @@ export * from './heos.classes.integration.js'; +export * from './heos.classes.client.js'; +export * from './heos.classes.configflow.js'; +export * from './heos.discovery.js'; +export * from './heos.mapper.js'; export * from './heos.types.js'; diff --git a/ts/integrations/ipp/.generated-by-smarthome-exchange b/ts/integrations/ipp/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/ipp/.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/ipp/index.ts b/ts/integrations/ipp/index.ts index 72a549d..003c62c 100644 --- a/ts/integrations/ipp/index.ts +++ b/ts/integrations/ipp/index.ts @@ -1,2 +1,6 @@ +export * from './ipp.classes.client.js'; +export * from './ipp.classes.configflow.js'; export * from './ipp.classes.integration.js'; +export * from './ipp.discovery.js'; +export * from './ipp.mapper.js'; export * from './ipp.types.js'; diff --git a/ts/integrations/ipp/ipp.classes.client.ts b/ts/integrations/ipp/ipp.classes.client.ts new file mode 100644 index 0000000..512ff97 --- /dev/null +++ b/ts/integrations/ipp/ipp.classes.client.ts @@ -0,0 +1,653 @@ +import type { + IIppAttributeRecord, + IIppClientLike, + IIppConfig, + IIppJobInfo, + IIppMarkerInfo, + IIppParsedResponse, + IIppPrinterInfo, + IIppSnapshot, + IIppStatusInfo, + TIppMarkerKind, + TIppPrinterState, + TIppSnapshotSource, +} from './ipp.types.js'; +import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js'; + +const operationGetPrinterAttributes = 0x000b; +const groupOperationAttributes = 0x01; +const endOfAttributes = 0x03; +const valueTagInteger = 0x21; +const valueTagBoolean = 0x22; +const valueTagEnum = 0x23; +const valueTagDateTime = 0x31; +const valueTagKeyword = 0x44; +const valueTagCharset = 0x47; +const valueTagNaturalLanguage = 0x48; + +const requestedPrinterAttributes = [ + 'printer-name', + 'printer-info', + 'printer-location', + 'printer-make-and-model', + 'printer-device-id', + 'printer-uuid', + 'printer-serial-number', + 'printer-more-info', + 'printer-uri-supported', + 'document-format-supported', + 'printer-state', + 'printer-state-message', + 'printer-state-reasons', + 'printer-is-accepting-jobs', + 'queued-job-count', + 'printer-up-time', + 'printer-current-time', + 'marker-names', + 'marker-types', + 'marker-colors', + 'marker-levels', + 'marker-low-levels', + 'marker-high-levels', +]; + +export class IppClient { + constructor(private readonly config: IIppConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot'); + } + + if (this.config.client) { + return this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client'); + } + + if (this.config.attributes) { + return this.normalizeSnapshot(this.snapshotFromAttributes(this.config.attributes, this.config.online ?? true, 'manual'), 'manual'); + } + + if (this.config.host) { + try { + return this.normalizeSnapshot(await this.fetchSnapshot(), 'ipp'); + } catch (errorArg) { + return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime'); + } + } + + return this.normalizeSnapshot(this.snapshotFromConfig(false, 'IPP refresh requires config.host, config.snapshot, config.attributes, or config.client.'), 'runtime'); + } + + public async refresh(): Promise { + return this.getSnapshot(); + } + + public async ping(): Promise { + const snapshot = await this.getSnapshot(); + return snapshot.online && snapshot.source !== 'runtime' && !snapshot.error; + } + + public hasUsableSource(): boolean { + return Boolean(this.config.host || this.config.snapshot || this.config.attributes || this.config.client); + } + + public async destroy(): Promise {} + + public static parseResponse(dataArg: Uint8Array): IIppParsedResponse { + if (dataArg.length < 8) { + throw new Error('IPP response is too short.'); + } + + const data = Buffer.from(dataArg.buffer, dataArg.byteOffset, dataArg.byteLength); + const attributes: IIppAttributeRecord = {}; + const version = `${data[0]}.${data[1]}`; + const statusCode = data.readUInt16BE(2); + const requestId = data.readUInt32BE(4); + let offset = 8; + let lastName = ''; + + while (offset < data.length) { + const tag = data[offset++]; + if (tag === endOfAttributes) { + break; + } + if (isDelimiterTag(tag)) { + lastName = ''; + continue; + } + + if (offset + 2 > data.length) { + throw new Error('IPP response ended while reading attribute name length.'); + } + const nameLength = data.readUInt16BE(offset); + offset += 2; + if (offset + nameLength + 2 > data.length) { + throw new Error('IPP response ended while reading attribute name.'); + } + const name = nameLength ? data.subarray(offset, offset + nameLength).toString('utf8') : lastName; + offset += nameLength; + const valueLength = data.readUInt16BE(offset); + offset += 2; + if (offset + valueLength > data.length) { + throw new Error('IPP response ended while reading attribute value.'); + } + const valueBytes = data.subarray(offset, offset + valueLength); + offset += valueLength; + + if (!name) { + continue; + } + lastName = name; + addAttributeValue(attributes, name, parseIppValue(tag, valueBytes)); + } + + return { version, statusCode, requestId, attributes }; + } + + public static attributesToSnapshot(attributesArg: IIppAttributeRecord, configArg: Partial = {}, onlineArg = true, sourceArg: TIppSnapshotSource = 'manual', statusCodeArg?: number): IIppSnapshot { + return new IppClient(configArg).snapshotFromAttributes(attributesArg, onlineArg, sourceArg, statusCodeArg); + } + + private async snapshotFromClient(clientArg: IIppClientLike): Promise { + const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.printer ? await clientArg.printer() : undefined; + if (!result) { + throw new Error('IPP client must expose getSnapshot() or printer().'); + } + if (isIppSnapshot(result)) { + return result; + } + return this.snapshotFromAttributes(result, true, 'client'); + } + + private async fetchSnapshot(): Promise { + const requestBody = this.buildGetPrinterAttributesRequest(); + const response = await this.fetchWithTimeout(this.endpointUrl(), { + method: 'POST', + headers: { + accept: 'application/ipp', + 'content-type': 'application/ipp', + }, + body: requestBody.buffer.slice(requestBody.byteOffset, requestBody.byteOffset + requestBody.byteLength) as ArrayBuffer, + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`IPP printer request failed with HTTP ${response.status}${text ? `: ${text}` : ''}`); + } + + const parsed = IppClient.parseResponse(new Uint8Array(await response.arrayBuffer())); + if (parsed.statusCode < 0x0000 || parsed.statusCode >= 0x0400) { + throw new Error(`IPP printer returned status 0x${parsed.statusCode.toString(16).padStart(4, '0')}.`); + } + return this.snapshotFromAttributes(parsed.attributes, true, 'ipp', parsed.statusCode); + } + + private snapshotFromAttributes(attributesArg: IIppAttributeRecord, onlineArg: boolean, sourceArg: TIppSnapshotSource, statusCodeArg?: number): IIppSnapshot { + const attributes = normalizeAttributes(attributesArg); + const updatedAt = new Date().toISOString(); + const deviceId = stringValue(firstValue(attributes['printer-device-id'])); + const deviceInfo = parseDeviceId(deviceId); + const makeAndModel = stringValue(firstValue(attributes['printer-make-and-model'])); + const uuid = this.config.uuid || stringValue(firstValue(attributes['printer-uuid'])); + const serialNumber = this.config.serial || stringValue(firstValue(attributes['printer-serial-number'])) || deviceInfo.SERIALNUMBER || deviceInfo.SN || deviceInfo.SERN; + const printerState = printerStateValue(firstValue(attributes['printer-state'])); + const currentTime = dateString(firstValue(attributes['printer-current-time'])); + const uptimeSeconds = numberValue(firstValue(attributes['printer-up-time'])); + const bootedAt = uptimeSeconds !== undefined ? new Date(Date.parse(currentTime || updatedAt) - uptimeSeconds * 1000).toISOString() : undefined; + const parsed = parsedHost(this.config.host); + const host = parsed?.host || this.config.host; + const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined); + const basePath = this.basePath(); + const name = this.config.name || stringValue(firstValue(attributes['printer-name'])) || stringValue(firstValue(attributes['printer-info'])) || makeAndModel || host || 'IPP printer'; + const printer: IIppPrinterInfo = { + id: this.config.uniqueId || uuid || serialNumber || (host ? `${host}:${port}${basePath}` : undefined) || name, + name, + manufacturer: this.config.manufacturer || deviceInfo.MFG || deviceInfo.MANUFACTURER || manufacturerFromMakeAndModel(makeAndModel), + model: this.config.model || deviceInfo.MDL || deviceInfo.MODEL || makeAndModel, + serialNumber, + uuid, + version: stringValue(firstValue(attributes['printer-firmware-name'])) || deviceInfo.VERSION, + location: stringValue(firstValue(attributes['printer-location'])), + info: stringValue(firstValue(attributes['printer-info'])), + moreInfo: stringValue(firstValue(attributes['printer-more-info'])), + makeAndModel, + deviceId, + commandSet: splitList(deviceInfo.CMD || deviceInfo.COMMANDSET || deviceInfo['COMMAND SET']), + uriSupported: stringValues(attributes['printer-uri-supported']), + host, + port, + basePath, + tls: this.tls(), + }; + const status: IIppStatusInfo = { + printerState, + stateMessage: stringValue(firstValue(attributes['printer-state-message'])), + stateReasons: stringValues(attributes['printer-state-reasons']), + acceptingJobs: booleanValue(firstValue(attributes['printer-is-accepting-jobs'])), + queuedJobCount: numberValue(firstValue(attributes['queued-job-count'])), + uptimeSeconds, + bootedAt, + currentTime, + }; + + return { + printer, + status, + markers: this.markersFromAttributes(attributes), + jobs: this.jobsFromAttributes(attributes), + attributes, + online: onlineArg, + updatedAt, + source: sourceArg, + rawStatusCode: statusCodeArg, + }; + } + + private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IIppSnapshot { + const parsed = parsedHost(this.config.host); + const host = parsed?.host || this.config.host; + const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined); + const basePath = this.basePath(); + const name = this.config.name || this.config.model || host || 'IPP printer'; + return { + printer: { + id: this.config.uniqueId || this.config.uuid || this.config.serial || (host ? `${host}:${port}${basePath}` : undefined) || name, + name, + manufacturer: this.config.manufacturer, + model: this.config.model, + serialNumber: this.config.serial, + uuid: this.config.uuid, + host, + port, + basePath, + tls: this.tls(), + }, + status: { + printerState: 'unknown', + stateReasons: [], + }, + markers: [], + jobs: [], + online: onlineArg, + updatedAt: new Date().toISOString(), + source: 'runtime', + error: errorArg, + }; + } + + private normalizeSnapshot(snapshotArg: IIppSnapshot, sourceArg: TIppSnapshotSource): IIppSnapshot { + const derived = snapshotArg.attributes ? this.snapshotFromAttributes(snapshotArg.attributes, snapshotArg.online, sourceArg, snapshotArg.rawStatusCode) : undefined; + const printer = { + ...derived?.printer, + ...snapshotArg.printer, + }; + printer.name = printer.name || this.config.name || this.config.host || 'IPP printer'; + printer.id = printer.id || this.config.uniqueId || printer.uuid || printer.serialNumber || printer.name; + printer.host = printer.host || this.config.host; + printer.port = printer.port || (printer.host ? this.config.port || ippDefaultPort : this.config.port); + printer.basePath = printer.basePath || this.basePath(); + printer.tls = printer.tls ?? this.tls(); + + return { + ...snapshotArg, + printer, + status: this.normalizeStatus(derived?.status, snapshotArg.status), + markers: snapshotArg.markers?.length ? snapshotArg.markers : derived?.markers || [], + jobs: snapshotArg.jobs?.length ? snapshotArg.jobs : derived?.jobs || [], + attributes: snapshotArg.attributes || derived?.attributes, + online: snapshotArg.online, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + source: snapshotArg.source || sourceArg, + }; + } + + private markersFromAttributes(attributesArg: IIppAttributeRecord): IIppMarkerInfo[] { + const names = stringValues(attributesArg['marker-names']); + const types = stringValues(attributesArg['marker-types']); + const colors = stringValues(attributesArg['marker-colors']); + const levels = numberValues(attributesArg['marker-levels']); + const lowLevels = numberValues(attributesArg['marker-low-levels']); + const highLevels = numberValues(attributesArg['marker-high-levels']); + const count = Math.max(names.length, types.length, colors.length, levels.length, lowLevels.length, highLevels.length); + const markers: IIppMarkerInfo[] = []; + for (let index = 0; index < count; index++) { + const type = types[index]; + const level = levels[index]; + markers.push({ + id: `marker_${index}`, + index, + name: names[index] || markerName(type, colors[index], index), + kind: markerKind(type, names[index]), + type, + color: colors[index], + level: level !== undefined && level >= 0 ? level : level !== undefined ? null : undefined, + lowLevel: lowLevels[index], + highLevel: highLevels[index], + }); + } + return markers; + } + + private jobsFromAttributes(attributesArg: IIppAttributeRecord): IIppJobInfo[] { + const ids = stringValues(attributesArg['job-id']); + const names = stringValues(attributesArg['job-name']); + const states = values(attributesArg['job-state']).map((valueArg) => jobStateValue(valueArg)); + const owners = stringValues(attributesArg['job-originating-user-name']); + const impressionsCompleted = numberValues(attributesArg['job-impressions-completed']); + const createdAt = values(attributesArg['time-at-creation']).map((valueArg) => dateString(valueArg)); + const processingAt = values(attributesArg['time-at-processing']).map((valueArg) => dateString(valueArg)); + const completedAt = values(attributesArg['time-at-completed']).map((valueArg) => dateString(valueArg)); + const count = Math.max(ids.length, names.length, states.length, owners.length, impressionsCompleted.length, createdAt.length, processingAt.length, completedAt.length); + const jobs: IIppJobInfo[] = []; + for (let index = 0; index < count; index++) { + jobs.push({ + id: ids[index] || String(index + 1), + name: names[index], + state: states[index], + owner: owners[index], + impressionsCompleted: impressionsCompleted[index], + createdAt: createdAt[index], + processingAt: processingAt[index], + completedAt: completedAt[index], + }); + } + return jobs; + } + + private buildGetPrinterAttributesRequest(): Buffer { + const chunks: Buffer[] = []; + chunks.push(Buffer.from([0x01, 0x01])); + chunks.push(uint16(operationGetPrinterAttributes)); + chunks.push(uint32(Math.floor(Math.random() * 0x7fffffff) + 1)); + chunks.push(Buffer.from([groupOperationAttributes])); + writeAttribute(chunks, valueTagCharset, 'attributes-charset', 'utf-8'); + writeAttribute(chunks, valueTagNaturalLanguage, 'attributes-natural-language', 'en'); + writeAttribute(chunks, 0x45, 'printer-uri', this.printerUri()); + requestedPrinterAttributes.forEach((attributeArg, indexArg) => writeAttribute(chunks, valueTagKeyword, indexArg === 0 ? 'requested-attributes' : '', attributeArg)); + chunks.push(Buffer.from([endOfAttributes])); + return Buffer.concat(chunks); + } + + private endpointUrl(): string { + const host = this.config.host; + if (!host) { + throw new Error('IPP host is required for live printer refresh.'); + } + const parsed = parsedHost(host); + const tls = parsed?.tls ?? this.tls(); + const endpointHost = parsed?.host || host; + const port = this.config.port || parsed?.port || ippDefaultPort; + const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath; + return `${tls ? 'https' : 'http'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`; + } + + private printerUri(): string { + const host = this.config.host; + if (!host) { + throw new Error('IPP host is required for printer-uri.'); + } + const parsed = parsedHost(host); + const tls = parsed?.tls ?? this.tls(); + const endpointHost = parsed?.host || host; + const port = this.config.port || parsed?.port || ippDefaultPort; + const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath; + return `${tls ? 'ipps' : 'ipp'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`; + } + + private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || ippDefaultTimeoutMs); + try { + return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal }); + } finally { + clearTimeout(timeout); + } + } + + private basePath(): string { + return normalizeBasePath(this.config.basePath || parsedHost(this.config.host)?.basePath || ippDefaultBasePath); + } + + private tls(): boolean { + return this.config.tls ?? this.config.ssl ?? parsedHost(this.config.host)?.tls ?? false; + } + + private cloneSnapshot(snapshotArg: IIppSnapshot): IIppSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IIppSnapshot; + } + + private normalizeStatus(derivedArg: IIppStatusInfo | undefined, snapshotArg: IIppStatusInfo): IIppStatusInfo { + return { + ...derivedArg, + ...snapshotArg, + printerState: snapshotArg.printerState || derivedArg?.printerState || 'unknown', + stateReasons: snapshotArg.stateReasons || derivedArg?.stateReasons || [], + }; + } +} + +const isDelimiterTag = (tagArg: number): boolean => tagArg >= 0x01 && tagArg <= 0x0f; + +const writeAttribute = (chunksArg: Buffer[], tagArg: number, nameArg: string, valueArg: string): void => { + const name = Buffer.from(nameArg, 'utf8'); + const value = Buffer.from(valueArg, 'utf8'); + chunksArg.push(Buffer.from([tagArg])); + chunksArg.push(uint16(name.length)); + chunksArg.push(name); + chunksArg.push(uint16(value.length)); + chunksArg.push(value); +}; + +const uint16 = (valueArg: number): Buffer => { + const buffer = Buffer.alloc(2); + buffer.writeUInt16BE(valueArg, 0); + return buffer; +}; + +const uint32 = (valueArg: number): Buffer => { + const buffer = Buffer.alloc(4); + buffer.writeUInt32BE(valueArg, 0); + return buffer; +}; + +const parseIppValue = (tagArg: number, valueArg: Buffer): unknown => { + if (tagArg === 0x10 || tagArg === 0x12 || tagArg === 0x13) { + return null; + } + if ((tagArg === valueTagInteger || tagArg === valueTagEnum) && valueArg.length >= 4) { + return valueArg.readInt32BE(0); + } + if (tagArg === valueTagBoolean && valueArg.length >= 1) { + return valueArg[0] !== 0; + } + if (tagArg === valueTagDateTime && valueArg.length >= 11) { + return parseIppDateTime(valueArg); + } + if (tagArg === 0x33 && valueArg.length >= 8) { + return { lower: valueArg.readInt32BE(0), upper: valueArg.readInt32BE(4) }; + } + if (tagArg === 0x32 && valueArg.length >= 9) { + return { x: valueArg.readInt32BE(0), y: valueArg.readInt32BE(4), units: valueArg[8] === 3 ? 'dpi' : 'dpcm' }; + } + if (tagArg >= 0x40 || tagArg === 0x30 || tagArg === 0x35 || tagArg === 0x36) { + return valueArg.toString('utf8'); + } + return valueArg.toString('hex'); +}; + +const parseIppDateTime = (valueArg: Buffer): string | undefined => { + const year = valueArg.readUInt16BE(0); + const month = valueArg[2]; + const day = valueArg[3]; + const hour = valueArg[4]; + const minute = valueArg[5]; + const second = valueArg[6]; + const decisecond = valueArg[7]; + const direction = String.fromCharCode(valueArg[8]); + const offsetMinutes = valueArg[9] * 60 + valueArg[10]; + const local = Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second, decisecond * 100); + const adjustment = direction === '+' ? -offsetMinutes : direction === '-' ? offsetMinutes : 0; + const timestamp = local + adjustment * 60_000; + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; +}; + +const addAttributeValue = (attributesArg: IIppAttributeRecord, nameArg: string, valueArg: unknown): void => { + const existing = attributesArg[nameArg]; + if (existing === undefined) { + attributesArg[nameArg] = valueArg; + return; + } + if (Array.isArray(existing)) { + existing.push(valueArg); + return; + } + attributesArg[nameArg] = [existing, valueArg]; +}; + +const normalizeAttributes = (attributesArg: IIppAttributeRecord): IIppAttributeRecord => { + const normalized: IIppAttributeRecord = {}; + for (const [key, value] of Object.entries(attributesArg || {})) { + normalized[key.trim()] = value; + } + return normalized; +}; + +const isIppSnapshot = (valueArg: IIppSnapshot | IIppAttributeRecord): valueArg is IIppSnapshot => { + return Boolean(valueArg && typeof valueArg === 'object' && 'printer' in valueArg && 'status' in valueArg && 'markers' in valueArg && 'online' in valueArg); +}; + +const values = (valueArg: unknown): unknown[] => Array.isArray(valueArg) ? valueArg : valueArg === undefined ? [] : [valueArg]; + +const firstValue = (valueArg: unknown): unknown => values(valueArg)[0]; + +const stringValue = (valueArg: unknown): string | undefined => { + if (typeof valueArg === 'string' && valueArg.trim()) { + return valueArg.trim(); + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return String(valueArg); + } + return undefined; +}; + +const stringValues = (valueArg: unknown): string[] => values(valueArg).map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg)); + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg.match(/[-+]?\d+(?:\.\d+)?/)?.[0]); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const numberValues = (valueArg: unknown): number[] => values(valueArg).map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => itemArg !== undefined); + +const booleanValue = (valueArg: unknown): boolean | undefined => { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + const normalized = valueArg.trim().toLowerCase(); + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return true; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return false; + } + } + return undefined; +}; + +const dateString = (valueArg: unknown): string | undefined => { + if (typeof valueArg === 'string') { + const parsed = Date.parse(valueArg); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; + } + return undefined; +}; + +const printerStateValue = (valueArg: unknown): TIppPrinterState => { + if (typeof valueArg === 'number') { + return valueArg === 3 ? 'idle' : valueArg === 4 ? 'printing' : valueArg === 5 ? 'stopped' : 'unknown'; + } + const value = stringValue(valueArg)?.toLowerCase(); + return value === 'processing' ? 'printing' : value === 'idle' || value === 'printing' || value === 'stopped' ? value : value || 'unknown'; +}; + +const jobStateValue = (valueArg: unknown): string | undefined => { + if (typeof valueArg === 'number') { + return ({ 3: 'pending', 4: 'pending-held', 5: 'processing', 6: 'processing-stopped', 7: 'canceled', 8: 'aborted', 9: 'completed' } as Record)[valueArg] || String(valueArg); + } + return stringValue(valueArg); +}; + +const parseDeviceId = (valueArg: string | undefined): Record => { + const result: Record = {}; + for (const part of (valueArg || '').split(';')) { + const separator = part.indexOf(':'); + if (separator <= 0) { + continue; + } + const key = part.slice(0, separator).trim().toUpperCase().replace(/[\s-]+/g, ''); + const value = part.slice(separator + 1).trim(); + if (key && value) { + result[key] = value; + } + } + return result; +}; + +const splitList = (valueArg: string | undefined): string[] => (valueArg || '').split(/[,;]/).map((partArg) => partArg.trim()).filter(Boolean); + +const manufacturerFromMakeAndModel = (valueArg: string | undefined): string | undefined => { + const firstWord = valueArg?.trim().split(/\s+/)[0]; + return firstWord && firstWord.length > 1 ? firstWord : undefined; +}; + +const markerKind = (typeArg: string | undefined, nameArg: string | undefined): TIppMarkerKind => { + const haystack = `${typeArg || ''} ${nameArg || ''}`.toLowerCase(); + if (haystack.includes('toner')) { + return 'toner'; + } + if (haystack.includes('ink')) { + return 'ink'; + } + if (haystack.includes('drum') || haystack.includes('opc')) { + return 'drum'; + } + if (haystack.includes('waste')) { + return 'waste'; + } + return 'marker'; +}; + +const markerName = (typeArg: string | undefined, colorArg: string | undefined, indexArg: number): string => { + const parts = [colorArg, typeArg].filter(Boolean); + return parts.length ? parts.join(' ') : `Marker ${indexArg + 1}`; +}; + +const normalizeBasePath = (valueArg: string | undefined): string => { + const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath; + return value.startsWith('/') ? value : `/${value}`; +}; + +const parsedHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => { + if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) { + return undefined; + } + try { + const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:')); + return { + host: url.hostname, + port: url.port ? Number(url.port) : undefined, + basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined, + tls: url.protocol === 'https:', + }; + } catch { + return undefined; + } +}; + +const formatHost = (valueArg: string): string => valueArg.includes(':') && !valueArg.startsWith('[') ? `[${valueArg}]` : valueArg; diff --git a/ts/integrations/ipp/ipp.classes.configflow.ts b/ts/integrations/ipp/ipp.classes.configflow.ts new file mode 100644 index 0000000..76591bd --- /dev/null +++ b/ts/integrations/ipp/ipp.classes.configflow.ts @@ -0,0 +1,116 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IIppAttributeRecord, IIppConfig, IIppSnapshot } from './ipp.types.js'; +import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js'; + +export class IppConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect IPP printer', + description: 'Configure the local IPP printer endpoint.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'basePath', label: 'Relative path to the printer', type: 'text' }, + { name: 'tls', label: 'Use SSL/TLS', type: 'boolean' }, + { name: 'verifySsl', label: 'Verify SSL certificate', type: 'boolean' }, + { name: 'name', label: 'Name', type: 'text' }, + ], + submit: async (valuesArg) => { + const metadata = candidateArg.metadata || {}; + const snapshot = snapshotFromMetadata(metadata); + const attributes = attributesFromMetadata(metadata); + const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.printer.host || ''; + if (!host && !snapshot && !attributes && !metadata.client) { + return { kind: 'error', title: 'IPP setup failed', error: 'IPP setup requires a host, snapshot, attributes, or injected client.' }; + } + const basePath = this.basePathValue(valuesArg.basePath) || this.stringMetadata(metadata, 'basePath') || snapshot?.printer.basePath || ippDefaultBasePath; + const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.printer.port || ippDefaultPort; + const tls = this.booleanValue(valuesArg.tls) ?? this.booleanMetadata(metadata, 'tls') ?? snapshot?.printer.tls ?? false; + const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(metadata, 'verifySsl') ?? false; + const uuid = this.stringMetadata(metadata, 'uuid') || snapshot?.printer.uuid; + const serial = candidateArg.serialNumber || snapshot?.printer.serialNumber; + return { + kind: 'done', + title: 'IPP printer configured', + config: { + host, + port, + basePath, + tls, + verifySsl, + name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.printer.name, + manufacturer: candidateArg.manufacturer || snapshot?.printer.manufacturer, + model: candidateArg.model || snapshot?.printer.model, + uniqueId: candidateArg.id || uuid || serial || (host ? `${host}:${port}${basePath}` : undefined), + uuid, + serial, + timeoutMs: ippDefaultTimeoutMs, + snapshot, + attributes, + client: metadata.client as IIppConfig['client'], + }, + }; + }, + }; + } + + 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) && valueArg > 0) { + return Math.round(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined; + } + return undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + const normalized = valueArg.trim().toLowerCase(); + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return true; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return false; + } + } + return undefined; + } + + private basePathValue(valueArg: unknown): string | undefined { + const value = this.stringValue(valueArg); + if (!value) { + return undefined; + } + return value.startsWith('/') ? value : `/${value}`; + } + + private stringMetadata(metadataArg: Record, keyArg: string): string | undefined { + return this.stringValue(metadataArg[keyArg]); + } + + private booleanMetadata(metadataArg: Record, keyArg: string): boolean | undefined { + const value = metadataArg[keyArg]; + return typeof value === 'boolean' ? value : undefined; + } +} + +const snapshotFromMetadata = (metadataArg: Record): IIppSnapshot | undefined => { + const snapshot = metadataArg.snapshot; + return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined; +}; + +const attributesFromMetadata = (metadataArg: Record): IIppAttributeRecord | undefined => { + const attributes = metadataArg.attributes; + return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined; +}; diff --git a/ts/integrations/ipp/ipp.classes.integration.ts b/ts/integrations/ipp/ipp.classes.integration.ts index b2b5afc..8c49d9d 100644 --- a/ts/integrations/ipp/ipp.classes.integration.ts +++ b/ts/integrations/ipp/ipp.classes.integration.ts @@ -1,26 +1,83 @@ -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 { IppClient } from './ipp.classes.client.js'; +import { IppConfigFlow } from './ipp.classes.configflow.js'; +import { createIppDiscoveryDescriptor } from './ipp.discovery.js'; +import { IppMapper } from './ipp.mapper.js'; +import type { IIppConfig } from './ipp.types.js'; -export class HomeAssistantIppIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "ipp", - displayName: "Internet Printing Protocol (IPP)", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/ipp", - "upstreamDomain": "ipp", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "pyipp==0.17.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@ctalkington" - ] -}, - }); +export class IppIntegration extends BaseIntegration { + public readonly domain = 'ipp'; + public readonly displayName = 'Internet Printing Protocol (IPP)'; + public readonly status = 'read-only-runtime' as const; + public readonly discoveryDescriptor = createIppDiscoveryDescriptor(); + public readonly configFlow = new IppConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/ipp', + upstreamDomain: 'ipp', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['pyipp==0.17.0'], + dependencies: [], + afterDependencies: [], + codeowners: ['@ctalkington'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/ipp', + zeroconf: ['_ipps._tcp.local.', '_ipp._tcp.local.'], + runtime: { + type: 'read-only-runtime', + polling: 'local HTTP(S) IPP Get-Printer-Attributes request', + services: ['snapshot', 'status', 'refresh'], + controls: false, + }, + }; + + public async setup(configArg: IIppConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new IppRuntime(new IppClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantIppIntegration extends IppIntegration {} + +class IppRuntime implements IIntegrationRuntime { + public domain = 'ipp'; + + constructor(private readonly client: IppClient) {} + + public async devices(): Promise { + return IppMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return IppMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain !== 'ipp') { + return { success: false, error: `Unsupported IPP service domain: ${requestArg.domain}` }; + } + if (requestArg.service === 'snapshot' || requestArg.service === 'status') { + return { success: true, data: await this.client.getSnapshot() }; + } + if (requestArg.service === 'refresh') { + const snapshot = await this.client.refresh(); + return snapshot.source !== 'runtime' || snapshot.online + ? { success: true, data: snapshot } + : { success: false, error: snapshot.error || 'IPP refresh requires a host, snapshot, attributes, or client.', data: snapshot }; + } + return { success: false, error: `Unsupported IPP service: ${requestArg.service}` }; + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/ipp/ipp.discovery.ts b/ts/integrations/ipp/ipp.discovery.ts new file mode 100644 index 0000000..45a94dc --- /dev/null +++ b/ts/integrations/ipp/ipp.discovery.ts @@ -0,0 +1,219 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IIppAttributeRecord, IIppManualEntry, IIppMdnsRecord, IIppSnapshot } from './ipp.types.js'; +import { ippDefaultBasePath, ippDefaultPort } from './ipp.types.js'; + +const ippMdnsTypes = ['_ipp._tcp.local', '_ipps._tcp.local']; + +export class IppMdnsMatcher implements IDiscoveryMatcher { + public id = 'ipp-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize IPP and IPPS printer mDNS advertisements.'; + + public async matches(recordArg: IIppMdnsRecord): Promise { + const type = normalizeType(recordArg.type); + const properties = { ...recordArg.txt, ...recordArg.properties }; + const matched = ippMdnsTypes.includes(type); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not an IPP printer advertisement.' }; + } + + const tls = type === '_ipps._tcp.local'; + const uuid = valueForKey(properties, 'UUID') || valueForKey(properties, 'uuid'); + const rp = valueForKey(properties, 'rp') || ippDefaultBasePath; + const name = cleanName(recordArg.name || recordArg.hostname, type); + return { + matched: true, + confidence: uuid ? 'certain' : 'high', + reason: 'mDNS record matches IPP printer service metadata.', + normalizedDeviceId: uuid || recordArg.host || recordArg.addresses?.[0], + candidate: { + source: 'mdns', + integrationDomain: 'ipp', + id: uuid, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port || ippDefaultPort, + name: name || valueForKey(properties, 'ty'), + manufacturer: valueForKey(properties, 'usb_MFG'), + model: valueForKey(properties, 'usb_MDL') || valueForKey(properties, 'ty') || valueForKey(properties, 'product'), + metadata: { + ipp: true, + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: properties, + tls, + verifySsl: false, + basePath: normalizeBasePath(rp), + uuid, + rp, + }, + }, + }; + } +} + +export class IppManualMatcher implements IDiscoveryMatcher { + public id = 'ipp-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual IPP printer setup entries.'; + + public async matches(inputArg: IIppManualEntry): Promise { + const parsed = parseHost(inputArg.host); + const metadata = inputArg.metadata || {}; + const snapshot = inputArg.snapshot || snapshotFromMetadata(metadata); + const attributes = inputArg.attributes || attributesFromMetadata(metadata); + const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const matched = Boolean(inputArg.host || inputArg.port === ippDefaultPort || inputArg.basePath || metadata.ipp || snapshot || attributes || haystack.includes('ipp') || haystack.includes('printer')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain IPP setup hints.' }; + } + + const port = inputArg.port || parsed?.port || ippDefaultPort; + const tls = inputArg.tls ?? inputArg.ssl ?? parsed?.tls ?? false; + const basePath = normalizeBasePath(inputArg.basePath || parsed?.basePath || stringMetadata(metadata, 'basePath') || ippDefaultBasePath); + const id = inputArg.id || inputArg.uuid || inputArg.serialNumber || snapshot?.printer.uuid || snapshot?.printer.serialNumber || (inputArg.host ? `${parsed?.host || inputArg.host}:${port}${basePath}` : undefined); + return { + matched: true, + confidence: inputArg.host || snapshot || attributes ? 'high' : 'medium', + reason: 'Manual entry can start IPP printer setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'ipp', + id, + host: parsed?.host || inputArg.host, + port, + name: inputArg.name || snapshot?.printer.name, + manufacturer: inputArg.manufacturer || snapshot?.printer.manufacturer, + model: inputArg.model || snapshot?.printer.model, + serialNumber: inputArg.serialNumber || snapshot?.printer.serialNumber, + metadata: { + ...metadata, + ipp: true, + tls, + verifySsl: inputArg.verifySsl ?? booleanMetadata(metadata, 'verifySsl') ?? false, + basePath, + uuid: inputArg.uuid || snapshot?.printer.uuid, + snapshot, + attributes, + }, + }, + }; + } +} + +export class IppCandidateValidator implements IDiscoveryValidator { + public id = 'ipp-candidate-validator'; + public description = 'Validate IPP candidates have printer metadata and a usable host, snapshot, attributes, or client.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const type = normalizeType(stringMetadata(metadata, 'mdnsType')); + const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === 'ipp' + || candidateArg.port === ippDefaultPort + || Boolean(metadata.ipp) + || ippMdnsTypes.includes(type) + || haystack.includes('ipp') + || haystack.includes('printer'); + const hasUsableSource = Boolean(candidateArg.host || metadata.snapshot || metadata.attributes || metadata.client); + + if (!matched || !hasUsableSource) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'IPP candidate lacks host, snapshot, attributes, or client information.' : 'Candidate is not IPP.', + }; + } + + const basePath = normalizeBasePath(stringMetadata(metadata, 'basePath') || ippDefaultBasePath); + const normalizedDeviceId = candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || ippDefaultPort}${basePath}` : undefined); + return { + matched: true, + confidence: candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber ? 'certain' : 'high', + reason: 'Candidate has IPP metadata and a usable source.', + normalizedDeviceId, + candidate: { + ...candidateArg, + integrationDomain: 'ipp', + port: candidateArg.port || ippDefaultPort, + metadata: { + ...metadata, + ipp: true, + basePath, + tls: booleanMetadata(metadata, 'tls') ?? false, + verifySsl: booleanMetadata(metadata, 'verifySsl') ?? false, + }, + }, + }; + } +} + +export const createIppDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'ipp', displayName: 'Internet Printing Protocol (IPP)' }) + .addMatcher(new IppMdnsMatcher()) + .addMatcher(new IppManualMatcher()) + .addValidator(new IppCandidateValidator()); +}; + +const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, ''); + +const valueForKey = (recordArg: Record | undefined, keyArg: string): string | undefined => { + if (!recordArg) { + return undefined; + } + const lowerKey = keyArg.toLowerCase(); + for (const [key, value] of Object.entries(recordArg)) { + if (key.toLowerCase() === lowerKey) { + return value; + } + } + return undefined; +}; + +const cleanName = (valueArg: string | undefined, typeArg: string): string => { + const escaped = typeArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return valueArg?.replace(new RegExp(`\\.${escaped}\\.?$`, 'i'), '').replace(/\.local\.?$/i, '').trim() || ''; +}; + +const normalizeBasePath = (valueArg: string | undefined): string => { + const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath; + return value.startsWith('/') ? value : `/${value}`; +}; + +const parseHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => { + if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) { + return undefined; + } + try { + const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:')); + return { + host: url.hostname, + port: url.port ? Number(url.port) : undefined, + basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined, + tls: url.protocol === 'https:', + }; + } catch { + return undefined; + } +}; + +const stringMetadata = (metadataArg: Record, keyArg: string): string | undefined => { + const value = metadataArg[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +}; + +const booleanMetadata = (metadataArg: Record, keyArg: string): boolean | undefined => { + const value = metadataArg[keyArg]; + return typeof value === 'boolean' ? value : undefined; +}; + +const snapshotFromMetadata = (metadataArg: Record): IIppSnapshot | undefined => { + const snapshot = metadataArg.snapshot; + return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined; +}; + +const attributesFromMetadata = (metadataArg: Record): IIppAttributeRecord | undefined => { + const attributes = metadataArg.attributes; + return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined; +}; diff --git a/ts/integrations/ipp/ipp.mapper.ts b/ts/integrations/ipp/ipp.mapper.ts new file mode 100644 index 0000000..a0cca33 --- /dev/null +++ b/ts/integrations/ipp/ipp.mapper.ts @@ -0,0 +1,174 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IIppJobInfo, IIppMarkerInfo, IIppSnapshot } from './ipp.types.js'; + +const ippDomain = 'ipp'; + +export class IppMapper { + public static toDevices(snapshotArg: IIppSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'status', capability: 'sensor', name: 'Printer status', readable: true, writable: false }, + { id: 'queued_jobs', capability: 'sensor', name: 'Queued jobs', readable: true, writable: false }, + { id: 'accepting_jobs', capability: 'sensor', name: 'Accepting jobs', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'status', value: snapshotArg.status.printerState, updatedAt }, + { featureId: 'queued_jobs', value: snapshotArg.status.queuedJobCount ?? null, updatedAt }, + { featureId: 'accepting_jobs', value: snapshotArg.status.acceptingJobs ?? null, updatedAt }, + ]; + + for (const marker of snapshotArg.markers) { + const featureId = `marker_${marker.index}`; + features.push({ id: featureId, capability: 'sensor', name: marker.name, readable: true, writable: false, unit: '%' }); + state.push({ featureId, value: marker.level ?? null, updatedAt }); + } + + for (const job of snapshotArg.jobs) { + const featureId = `job_${this.slug(job.id)}`; + features.push({ id: featureId, capability: 'sensor', name: job.name || `Job ${job.id}`, readable: true, writable: false }); + state.push({ featureId, value: job.state || null, updatedAt }); + } + + return [{ + id: this.printerDeviceId(snapshotArg), + integrationDomain: ippDomain, + name: this.printerName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.printer.manufacturer || 'Unknown', + model: snapshotArg.printer.model || snapshotArg.printer.makeAndModel || 'IPP printer', + online: snapshotArg.online, + features, + state, + metadata: this.cleanAttributes({ + serialNumber: snapshotArg.printer.serialNumber, + uuid: snapshotArg.printer.uuid, + location: snapshotArg.printer.location, + info: snapshotArg.printer.info, + moreInfo: snapshotArg.printer.moreInfo, + commandSet: snapshotArg.printer.commandSet, + uriSupported: snapshotArg.printer.uriSupported, + host: snapshotArg.printer.host, + port: snapshotArg.printer.port, + basePath: snapshotArg.printer.basePath, + tls: snapshotArg.printer.tls, + source: snapshotArg.source, + stateReasons: snapshotArg.status.stateReasons, + markerCount: snapshotArg.markers.length, + jobCount: snapshotArg.jobs.length, + error: snapshotArg.error, + }), + }]; + } + + public static toEntities(snapshotArg: IIppSnapshot): IIntegrationEntity[] { + const deviceId = this.printerDeviceId(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + const baseName = this.printerName(snapshotArg); + const entities: IIntegrationEntity[] = []; + + entities.push(this.entity('sensor', 'printer', `${baseName} Status`, snapshotArg.status.printerState, deviceId, uniqueBase, snapshotArg.online, { + deviceClass: 'enum', + options: ['idle', 'printing', 'stopped'], + info: snapshotArg.printer.info, + serial: snapshotArg.printer.serialNumber, + uuid: snapshotArg.printer.uuid, + location: snapshotArg.printer.location, + stateMessage: snapshotArg.status.stateMessage, + stateReason: snapshotArg.status.stateReasons, + commandSet: snapshotArg.printer.commandSet, + uriSupported: snapshotArg.printer.uriSupported?.join(','), + manufacturer: snapshotArg.printer.manufacturer, + model: snapshotArg.printer.model, + source: snapshotArg.source, + error: snapshotArg.error, + })); + + if (snapshotArg.status.bootedAt) { + entities.push(this.entity('sensor', 'uptime', `${baseName} Uptime`, snapshotArg.status.bootedAt, deviceId, uniqueBase, snapshotArg.online, { + deviceClass: 'timestamp', + entityCategory: 'diagnostic', + })); + } + + if (snapshotArg.status.queuedJobCount !== undefined) { + entities.push(this.entity('sensor', 'queued_jobs', `${baseName} Queued Jobs`, snapshotArg.status.queuedJobCount, deviceId, uniqueBase, snapshotArg.online, { + stateClass: 'measurement', + })); + } + + if (snapshotArg.status.acceptingJobs !== undefined) { + entities.push(this.entity('binary_sensor', 'accepting_jobs', `${baseName} Accepting Jobs`, snapshotArg.status.acceptingJobs ? 'on' : 'off', deviceId, uniqueBase, snapshotArg.online, { + deviceClass: 'running', + })); + } + + for (const marker of snapshotArg.markers) { + entities.push(this.markerEntity(marker, deviceId, uniqueBase, snapshotArg.online)); + } + + for (const job of snapshotArg.jobs) { + entities.push(this.jobEntity(job, deviceId, uniqueBase, baseName, snapshotArg.online)); + } + + return entities; + } + + public static printerDeviceId(snapshotArg: IIppSnapshot): string { + return `ipp.printer.${this.uniqueBase(snapshotArg)}`; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'ipp'; + } + + private static markerEntity(markerArg: IIppMarkerInfo, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean): IIntegrationEntity { + return this.entity('sensor', `marker_${markerArg.index}`, markerArg.name, markerArg.level ?? null, deviceIdArg, uniqueBaseArg, availableArg, { + unit: '%', + stateClass: 'measurement', + markerType: markerArg.type, + markerKind: markerArg.kind, + markerColor: markerArg.color, + markerLowLevel: markerArg.lowLevel, + markerHighLevel: markerArg.highLevel, + }); + } + + private static jobEntity(jobArg: IIppJobInfo, deviceIdArg: string, uniqueBaseArg: string, baseNameArg: string, availableArg: boolean): IIntegrationEntity { + return this.entity('sensor', `job_${this.slug(jobArg.id)}`, jobArg.name || `${baseNameArg} Job ${jobArg.id}`, jobArg.state || 'unknown', deviceIdArg, uniqueBaseArg, availableArg, { + jobId: jobArg.id, + owner: jobArg.owner, + impressionsCompleted: jobArg.impressionsCompleted, + createdAt: jobArg.createdAt, + processingAt: jobArg.processingAt, + completedAt: jobArg.completedAt, + ...jobArg.attributes, + }); + } + + private static entity(platformArg: IIntegrationEntity['platform'], keyArg: string, nameArg: string, stateArg: unknown, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean, attributesArg: Record): IIntegrationEntity { + return { + id: `${platformArg}.${this.slug(nameArg)}`, + uniqueId: `ipp_${uniqueBaseArg}_${keyArg}`, + integrationDomain: ippDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static printerName(snapshotArg: IIppSnapshot): string { + return snapshotArg.printer.name || snapshotArg.printer.model || snapshotArg.printer.host || 'IPP printer'; + } + + private static uniqueBase(snapshotArg: IIppSnapshot): string { + return this.slug(snapshotArg.printer.uuid || snapshotArg.printer.serialNumber || snapshotArg.printer.id || snapshotArg.printer.host || this.printerName(snapshotArg)); + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } +} diff --git a/ts/integrations/ipp/ipp.types.ts b/ts/integrations/ipp/ipp.types.ts index d3da541..5f6631e 100644 --- a/ts/integrations/ipp/ipp.types.ts +++ b/ts/integrations/ipp/ipp.types.ts @@ -1,4 +1,143 @@ -export interface IHomeAssistantIppConfig { - // TODO: replace with the TypeScript-native config for ipp. +export const ippDefaultPort = 631; +export const ippDefaultBasePath = '/ipp/print'; +export const ippDefaultTimeoutMs = 10000; + +export type TIppSnapshotSource = 'ipp' | 'client' | 'snapshot' | 'manual' | 'runtime'; +export type TIppPrinterState = 'idle' | 'printing' | 'stopped' | 'unknown' | string; +export type TIppMarkerKind = 'marker' | 'ink' | 'toner' | 'drum' | 'waste' | string; + +export interface IIppConfig { + host?: string; + port?: number; + basePath?: string; + tls?: boolean; + ssl?: boolean; + verifySsl?: boolean; + timeoutMs?: number; + name?: string; + manufacturer?: string; + model?: string; + uniqueId?: string; + uuid?: string; + serial?: string; + snapshot?: IIppSnapshot; + attributes?: IIppAttributeRecord; + client?: IIppClientLike; + online?: boolean; +} + +export interface IHomeAssistantIppConfig extends IIppConfig {} + +export interface IIppClientLike { + printer?: () => Promise; + getSnapshot?: () => Promise; +} + +export interface IIppAttributeRecord { [key: string]: unknown; } + +export interface IIppPrinterInfo { + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + uuid?: string; + version?: string; + location?: string; + info?: string; + moreInfo?: string; + makeAndModel?: string; + deviceId?: string; + commandSet?: string[]; + uriSupported?: string[]; + host?: string; + port?: number; + basePath?: string; + tls?: boolean; +} + +export interface IIppStatusInfo { + printerState: TIppPrinterState; + stateMessage?: string; + stateReasons: string[]; + acceptingJobs?: boolean; + queuedJobCount?: number; + uptimeSeconds?: number; + bootedAt?: string; + currentTime?: string; +} + +export interface IIppMarkerInfo { + id?: string; + index: number; + name: string; + kind: TIppMarkerKind; + type?: string; + color?: string; + level?: number | null; + lowLevel?: number; + highLevel?: number; +} + +export interface IIppJobInfo { + id: string; + name?: string; + state?: string; + owner?: string; + impressionsCompleted?: number; + createdAt?: string; + processingAt?: string; + completedAt?: string; + attributes?: Record; +} + +export interface IIppSnapshot { + printer: IIppPrinterInfo; + status: IIppStatusInfo; + markers: IIppMarkerInfo[]; + jobs: IIppJobInfo[]; + attributes?: IIppAttributeRecord; + online: boolean; + updatedAt?: string; + source?: TIppSnapshotSource; + error?: string; + rawStatusCode?: number; +} + +export interface IIppParsedResponse { + version: string; + statusCode: number; + requestId: number; + attributes: IIppAttributeRecord; +} + +export interface IIppMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + hostname?: string; + txt?: Record; + properties?: Record; +} + +export interface IIppManualEntry { + host?: string; + port?: number; + basePath?: string; + tls?: boolean; + ssl?: boolean; + verifySsl?: boolean; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + uuid?: string; + snapshot?: IIppSnapshot; + attributes?: IIppAttributeRecord; + metadata?: Record; +}