From 282283d3443faa803fc7b2102903a7a5086c945e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 18:26:11 +0000 Subject: [PATCH] Add native local device integrations --- .../test.airgradient.discovery.node.ts | 62 ++ .../test.airgradient.mapper.node.ts | 116 +++ .../test.android_ip_webcam.client.node.ts | 57 ++ .../test.android_ip_webcam.discovery.node.ts | 35 + .../test.android_ip_webcam.mapper.node.ts | 85 ++ test/apcupsd/test.apcupsd.client.node.ts | 45 + test/apcupsd/test.apcupsd.discovery.node.ts | 25 + test/apcupsd/test.apcupsd.mapper.node.ts | 61 ++ test/blebox/test.blebox.discovery.node.ts | 37 + test/blebox/test.blebox.mapper.node.ts | 94 ++ test/blebox/test.blebox.runtime.node.ts | 79 ++ .../test.broadlink.discovery.node.ts | 56 ++ test/broadlink/test.broadlink.mapper.node.ts | 138 +++ test/dsmr/test.dsmr.discovery.node.ts | 39 + test/dsmr/test.dsmr.mapper.node.ts | 73 ++ ts/index.ts | 12 + .../.generated-by-smarthome-exchange | 1 - .../airgradient/airgradient.classes.client.ts | 581 ++++++++++++ .../airgradient.classes.configflow.ts | 108 +++ .../airgradient.classes.integration.ts | 101 +- .../airgradient/airgradient.discovery.ts | 183 ++++ .../airgradient/airgradient.mapper.ts | 495 ++++++++++ .../airgradient/airgradient.types.ts | 214 ++++- ts/integrations/airgradient/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../android_ip_webcam.classes.client.ts | 529 +++++++++++ .../android_ip_webcam.classes.configflow.ts | 100 ++ .../android_ip_webcam.classes.integration.ts | 96 +- .../android_ip_webcam.discovery.ts | 146 +++ .../android_ip_webcam.mapper.ts | 331 +++++++ .../android_ip_webcam.types.ts | 222 ++++- ts/integrations/android_ip_webcam/index.ts | 4 + .../apcupsd/.generated-by-smarthome-exchange | 1 - .../apcupsd/apcupsd.classes.client.ts | 289 ++++++ .../apcupsd/apcupsd.classes.configflow.ts | 53 ++ .../apcupsd/apcupsd.classes.integration.ts | 105 ++- ts/integrations/apcupsd/apcupsd.discovery.ts | 93 ++ ts/integrations/apcupsd/apcupsd.mapper.ts | 152 +++ ts/integrations/apcupsd/apcupsd.types.ts | 107 ++- ts/integrations/apcupsd/index.ts | 4 + .../blebox/.generated-by-smarthome-exchange | 1 - .../blebox/blebox.classes.client.ts | 385 ++++++++ .../blebox/blebox.classes.configflow.ts | 60 ++ .../blebox/blebox.classes.integration.ts | 207 +++- ts/integrations/blebox/blebox.discovery.ts | 143 +++ ts/integrations/blebox/blebox.mapper.ts | 577 ++++++++++++ ts/integrations/blebox/blebox.types.ts | 177 +++- ts/integrations/blebox/index.ts | 4 + .../.generated-by-smarthome-exchange | 1 - .../broadlink/broadlink.classes.client.ts | 167 ++++ .../broadlink/broadlink.classes.configflow.ts | 99 ++ .../broadlink.classes.integration.ts | 120 ++- .../broadlink/broadlink.constants.ts | 49 + .../broadlink/broadlink.discovery.ts | 242 +++++ ts/integrations/broadlink/broadlink.mapper.ts | 882 ++++++++++++++++++ ts/integrations/broadlink/broadlink.packet.ts | 90 ++ ts/integrations/broadlink/broadlink.types.ts | 252 ++++- ts/integrations/broadlink/index.ts | 6 + .../dsmr/.generated-by-smarthome-exchange | 1 - ts/integrations/dsmr/dsmr.classes.client.ts | 183 ++++ .../dsmr/dsmr.classes.configflow.ts | 85 ++ .../dsmr/dsmr.classes.integration.ts | 116 ++- ts/integrations/dsmr/dsmr.constants.ts | 79 ++ ts/integrations/dsmr/dsmr.discovery.ts | 141 +++ ts/integrations/dsmr/dsmr.mapper.ts | 143 +++ ts/integrations/dsmr/dsmr.parser.ts | 533 +++++++++++ ts/integrations/dsmr/dsmr.types.ts | 192 +++- ts/integrations/dsmr/index.ts | 6 + ts/integrations/generated/index.ts | 20 +- 69 files changed, 9713 insertions(+), 182 deletions(-) create mode 100644 test/airgradient/test.airgradient.discovery.node.ts create mode 100644 test/airgradient/test.airgradient.mapper.node.ts create mode 100644 test/android_ip_webcam/test.android_ip_webcam.client.node.ts create mode 100644 test/android_ip_webcam/test.android_ip_webcam.discovery.node.ts create mode 100644 test/android_ip_webcam/test.android_ip_webcam.mapper.node.ts create mode 100644 test/apcupsd/test.apcupsd.client.node.ts create mode 100644 test/apcupsd/test.apcupsd.discovery.node.ts create mode 100644 test/apcupsd/test.apcupsd.mapper.node.ts create mode 100644 test/blebox/test.blebox.discovery.node.ts create mode 100644 test/blebox/test.blebox.mapper.node.ts create mode 100644 test/blebox/test.blebox.runtime.node.ts create mode 100644 test/broadlink/test.broadlink.discovery.node.ts create mode 100644 test/broadlink/test.broadlink.mapper.node.ts create mode 100644 test/dsmr/test.dsmr.discovery.node.ts create mode 100644 test/dsmr/test.dsmr.mapper.node.ts delete mode 100644 ts/integrations/airgradient/.generated-by-smarthome-exchange create mode 100644 ts/integrations/airgradient/airgradient.classes.client.ts create mode 100644 ts/integrations/airgradient/airgradient.classes.configflow.ts create mode 100644 ts/integrations/airgradient/airgradient.discovery.ts create mode 100644 ts/integrations/airgradient/airgradient.mapper.ts delete mode 100644 ts/integrations/android_ip_webcam/.generated-by-smarthome-exchange create mode 100644 ts/integrations/android_ip_webcam/android_ip_webcam.classes.client.ts create mode 100644 ts/integrations/android_ip_webcam/android_ip_webcam.classes.configflow.ts create mode 100644 ts/integrations/android_ip_webcam/android_ip_webcam.discovery.ts create mode 100644 ts/integrations/android_ip_webcam/android_ip_webcam.mapper.ts delete mode 100644 ts/integrations/apcupsd/.generated-by-smarthome-exchange create mode 100644 ts/integrations/apcupsd/apcupsd.classes.client.ts create mode 100644 ts/integrations/apcupsd/apcupsd.classes.configflow.ts create mode 100644 ts/integrations/apcupsd/apcupsd.discovery.ts create mode 100644 ts/integrations/apcupsd/apcupsd.mapper.ts delete mode 100644 ts/integrations/blebox/.generated-by-smarthome-exchange create mode 100644 ts/integrations/blebox/blebox.classes.client.ts create mode 100644 ts/integrations/blebox/blebox.classes.configflow.ts create mode 100644 ts/integrations/blebox/blebox.discovery.ts create mode 100644 ts/integrations/blebox/blebox.mapper.ts delete mode 100644 ts/integrations/broadlink/.generated-by-smarthome-exchange create mode 100644 ts/integrations/broadlink/broadlink.classes.client.ts create mode 100644 ts/integrations/broadlink/broadlink.classes.configflow.ts create mode 100644 ts/integrations/broadlink/broadlink.constants.ts create mode 100644 ts/integrations/broadlink/broadlink.discovery.ts create mode 100644 ts/integrations/broadlink/broadlink.mapper.ts create mode 100644 ts/integrations/broadlink/broadlink.packet.ts delete mode 100644 ts/integrations/dsmr/.generated-by-smarthome-exchange create mode 100644 ts/integrations/dsmr/dsmr.classes.client.ts create mode 100644 ts/integrations/dsmr/dsmr.classes.configflow.ts create mode 100644 ts/integrations/dsmr/dsmr.constants.ts create mode 100644 ts/integrations/dsmr/dsmr.discovery.ts create mode 100644 ts/integrations/dsmr/dsmr.mapper.ts create mode 100644 ts/integrations/dsmr/dsmr.parser.ts diff --git a/test/airgradient/test.airgradient.discovery.node.ts b/test/airgradient/test.airgradient.discovery.node.ts new file mode 100644 index 0000000..f61cd67 --- /dev/null +++ b/test/airgradient/test.airgradient.discovery.node.ts @@ -0,0 +1,62 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createAirgradientDiscoveryDescriptor } from '../../ts/integrations/airgradient/index.js'; + +tap.test('matches AirGradient zeroconf records', async () => { + const descriptor = createAirgradientDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const result = await mdnsMatcher.matches({ + type: '_airgradient._tcp.local.', + name: 'Living Room._airgradient._tcp.local.', + host: 'airgradient-123456.local', + port: 80, + txt: { + serialno: 'abcdef123456', + model: 'I-9PSL', + fw_ver: '3.1.2', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('certain'); + expect(result.normalizedDeviceId).toEqual('abcdef123456'); + expect(result.candidate?.integrationDomain).toEqual('airgradient'); + expect(result.candidate?.metadata?.firmwareSupported).toBeTrue(); +}); + +tap.test('matches manual AirGradient entries and validates supported firmware', async () => { + const descriptor = createAirgradientDiscoveryDescriptor(); + const manualMatcher = descriptor.getMatchers()[1]; + const result = await manualMatcher.matches({ + host: '192.168.1.75', + name: 'Living Room AirGradient', + serialNumber: 'abcdef123456', + firmwareVersion: '3.1.2', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.port).toEqual(80); + + const validator = descriptor.getValidators()[0]; + const validated = await validator.validate(result.candidate!, {}); + expect(validated.matched).toBeTrue(); + expect(validated.confidence).toEqual('certain'); +}); + +tap.test('rejects unsupported AirGradient firmware versions when known', async () => { + const validator = createAirgradientDiscoveryDescriptor().getValidators()[0]; + const result = await validator.validate({ + source: 'mdns', + integrationDomain: 'airgradient', + host: 'airgradient-old.local', + serialNumber: 'old123', + metadata: { + discoveryProtocol: 'zeroconf', + firmwareVersion: '3.0.9', + }, + }, {}); + + expect(result.matched).toBeFalse(); + expect(result.reason).toContain('below the supported minimum'); +}); + +export default tap.start(); diff --git a/test/airgradient/test.airgradient.mapper.node.ts b/test/airgradient/test.airgradient.mapper.node.ts new file mode 100644 index 0000000..472eb3e --- /dev/null +++ b/test/airgradient/test.airgradient.mapper.node.ts @@ -0,0 +1,116 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AirgradientClient, AirgradientMapper, type IAirgradientSnapshot } from '../../ts/integrations/airgradient/index.js'; + +const snapshot: IAirgradientSnapshot = { + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', + devices: [ + { + host: '192.168.1.75', + name: 'Living Room AirGradient', + serialNumber: 'abcdef123456', + model: 'I-9PSL', + modelName: 'AirGradient ONE', + firmwareVersion: '3.1.2', + latestFirmwareVersion: '3.2.0', + online: true, + measures: { + signalStrength: -49, + serialNumber: 'abcdef123456', + bootTime: 4, + firmwareVersion: '3.1.2', + model: 'I-9PSL', + pm01: 2, + pm02: 6.5, + rawPm02: 7, + pm10: 9, + ambientTemperature: 21.4, + relativeHumidity: 45, + rco2: 620, + totalVolatileOrganicComponentIndex: 3, + nitrogenIndex: 1, + pm003Count: 123, + }, + config: { + country: 'US', + pmStandard: 'us-aqi', + ledBarMode: 'co2', + co2AutomaticBaselineCalibrationDays: 30, + temperatureUnit: 'c', + configurationControl: 'local', + postDataToAirGradient: true, + ledBarBrightness: 80, + displayBrightness: 60, + noxLearningOffset: 60, + tvocLearningOffset: 120, + }, + }, + ], + events: [], +}; + +tap.test('maps AirGradient measures and config to devices and entities', async () => { + const devices = AirgradientMapper.toDevices(snapshot); + const entities = AirgradientMapper.toEntities(snapshot); + + expect(devices[0].id).toEqual('airgradient.device.abcdef123456'); + expect(devices[0].manufacturer).toEqual('AirGradient'); + expect(devices[0].features.some((featureArg) => featureArg.id === 'co2')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.living_room_airgradient_pm2_5')?.state).toEqual(6.5); + expect(entities.find((entityArg) => entityArg.id === 'sensor.living_room_airgradient_co2')?.state).toEqual(620); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_pm_standard') && entityArg.platform === 'select')?.state).toEqual('us_aqi'); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_brightness') && entityArg.platform === 'number')?.state).toEqual(60); + expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_update'))?.attributes?.latestVersion).toEqual('3.2.0'); +}); + +tap.test('maps AirGradient services to safe config payloads', async () => { + const entities = AirgradientMapper.toEntities(snapshot); + const pmStandardEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_pm_standard') && entityArg.platform === 'select')!; + const brightnessEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_brightness') && entityArg.platform === 'number')!; + const postDataEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_post_data_to_airgradient'))!; + const ledTestEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_led_bar_test'))!; + + const pmCommand = AirgradientMapper.commandForService(snapshot, { + domain: 'select', + service: 'select_option', + target: { entityId: pmStandardEntity.id }, + data: { option: 'ugm3' }, + }); + const brightnessCommand = AirgradientMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { entityId: brightnessEntity.id }, + data: { value: 42 }, + }); + const switchCommand = AirgradientMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: postDataEntity.id }, + }); + const buttonCommand = AirgradientMapper.commandForService(snapshot, { + domain: 'button', + service: 'press', + target: { entityId: ledTestEntity.id }, + }); + + expect(pmCommand?.type === 'set_config' ? pmCommand.payload : undefined).toEqual({ pmStandard: 'ugm3' }); + expect(brightnessCommand?.type === 'set_config' ? brightnessCommand.payload : undefined).toEqual({ displayBrightness: 42 }); + expect(switchCommand?.type === 'set_config' ? switchCommand.payload : undefined).toEqual({ postDataToAirGradient: false }); + expect(buttonCommand?.type === 'set_config' ? buttonCommand.payload : undefined).toEqual({ ledBarTestRequested: true }); + expect(AirgradientMapper.commandForService(snapshot, { domain: 'airgradient', service: 'set_config', target: {}, data: { field: 'unknown', value: true } })).toBeUndefined(); +}); + +tap.test('does not fake live AirGradient command success for snapshot-only configs', async () => { + const client = new AirgradientClient({ snapshot }); + const command = AirgradientMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { entityId: 'number.living_room_airgradient_display_brightness' }, + data: { value: 50 }, + })!; + const result = await client.sendCommand(command); + expect(result.success).toBeFalse(); + expect(result.error).toContain('requires config.host'); +}); + +export default tap.start(); diff --git a/test/android_ip_webcam/test.android_ip_webcam.client.node.ts b/test/android_ip_webcam/test.android_ip_webcam.client.node.ts new file mode 100644 index 0000000..09c54db --- /dev/null +++ b/test/android_ip_webcam/test.android_ip_webcam.client.node.ts @@ -0,0 +1,57 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidIpWebcamClient } from '../../ts/integrations/android_ip_webcam/index.js'; + +tap.test('fetches live snapshots and only reports command success after Ok response', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL) => { + const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url; + requests.push(url); + if (url.endsWith('/status.json?show_avail=1')) { + return new Response(JSON.stringify({ + curvals: { torch: 'off', motion_detect: 'on' }, + avail: { torch: ['on', 'off'] }, + audio_connections: 1, + video_connections: 0, + }), { headers: { 'content-type': 'application/json' } }); + } + if (url.endsWith('/sensors.json')) { + return new Response(JSON.stringify({ + battery_level: { unit: '%', data: [[ [87, 123] ]] }, + motion_active: { data: [[ [1, 123] ]] }, + }), { headers: { 'content-type': 'application/json' } }); + } + if (url.endsWith('/enabletorch')) { + return new Response('Ok'); + } + return new Response('Not Found', { status: 404 }); + }) as typeof fetch; + + try { + const client = new AndroidIpWebcamClient({ host: '192.168.1.20', port: 8080 }); + const snapshot = await client.getSnapshot(); + expect(snapshot.connected).toBeTrue(); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'battery_level')?.value).toEqual(87); + expect(snapshot.binarySensors[0].isOn).toBeTrue(); + expect(snapshot.switches.find((switchArg) => switchArg.key === 'torch')?.isOn).toEqual(false); + + const result = await client.execute({ type: 'torch', service: 'set_torch', activate: true }); + expect(result).toEqual({ ok: true, key: 'torch', value: true }); + expect(requests.some((requestArg) => requestArg.endsWith('/enabletorch'))).toBeTrue(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('does not pretend live commands succeeded without a live endpoint', async () => { + const client = new AndroidIpWebcamClient({}); + let error = ''; + try { + await client.execute({ type: 'torch', service: 'set_torch', activate: true }); + } catch (errorArg) { + error = errorArg instanceof Error ? errorArg.message : String(errorArg); + } + expect(error.includes('requires config.host or config.url')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/android_ip_webcam/test.android_ip_webcam.discovery.node.ts b/test/android_ip_webcam/test.android_ip_webcam.discovery.node.ts new file mode 100644 index 0000000..7e063e4 --- /dev/null +++ b/test/android_ip_webcam/test.android_ip_webcam.discovery.node.ts @@ -0,0 +1,35 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidIpWebcamConfigFlow, createAndroidIpWebcamDiscoveryDescriptor } from '../../ts/integrations/android_ip_webcam/index.js'; + +tap.test('matches manual Android IP Webcam URL entries', async () => { + const descriptor = createAndroidIpWebcamDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const match = await matcher.matches({ url: 'http://192.168.1.20:8080', name: 'Kitchen Phone' }, {}); + + expect(match.matched).toBeTrue(); + expect(match.candidate?.integrationDomain).toEqual('android_ip_webcam'); + expect(match.candidate?.host).toEqual('192.168.1.20'); + expect(match.candidate?.port).toEqual(8080); + expect(match.candidate?.metadata?.url).toEqual('http://192.168.1.20:8080'); + + const validation = await descriptor.getValidators()[0].validate(match.candidate!, {}); + expect(validation.matched).toBeTrue(); +}); + +tap.test('matches manual Android IP Webcam host entries and configures flow', async () => { + const descriptor = createAndroidIpWebcamDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const match = await matcher.matches({ host: '192.168.1.21', name: 'Desk Phone' }, {}); + expect(match.matched).toBeTrue(); + expect(match.candidate?.port).toEqual(8080); + + const flow = new AndroidIpWebcamConfigFlow(); + const step = await flow.start(match.candidate!, {}); + const done = await step.submit!({ username: 'user', password: 'pass' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.21'); + expect(done.config?.port).toEqual(8080); + expect(done.config?.username).toEqual('user'); +}); + +export default tap.start(); diff --git a/test/android_ip_webcam/test.android_ip_webcam.mapper.node.ts b/test/android_ip_webcam/test.android_ip_webcam.mapper.node.ts new file mode 100644 index 0000000..77c23be --- /dev/null +++ b/test/android_ip_webcam/test.android_ip_webcam.mapper.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidIpWebcamMapper, type IAndroidIpWebcamSnapshot } from '../../ts/integrations/android_ip_webcam/index.js'; + +const snapshot: IAndroidIpWebcamSnapshot = { + deviceInfo: { + id: 'kitchen-phone', + name: 'Kitchen Phone', + manufacturer: 'Android IP Webcam', + host: '192.168.1.20', + port: 8080, + protocol: 'http', + online: true, + }, + camera: { + id: 'camera', + name: 'Kitchen Phone Camera', + mjpegUrl: 'http://192.168.1.20:8080/video', + imageUrl: 'http://192.168.1.20:8080/shot.jpg', + rtspUrl: 'rtsp://192.168.1.20:8080/h264_aac.sdp', + supportedFeatures: ['stream'], + available: true, + }, + sensors: [ + { key: 'audio_connections', name: 'Audio connections', value: 1, stateClass: 'total', entityCategory: 'diagnostic', available: true }, + { key: 'battery_level', name: 'Battery level', value: 87, unit: '%', deviceClass: 'battery', stateClass: 'measurement', entityCategory: 'diagnostic', available: true }, + ], + binarySensors: [{ key: 'motion_active', name: 'Motion active', isOn: true, deviceClass: 'motion', available: true }], + switches: [ + { key: 'torch', name: 'Torch', isOn: false, command: 'torch', entityCategory: 'config', available: true }, + { key: 'motion_detect', name: 'Motion detection', isOn: true, command: 'setting', entityCategory: 'config', available: true }, + ], + statusData: { audio_connections: 1, curvals: { torch: 'off', motion_detect: 'on' } }, + sensorData: { battery_level: { unit: '%', data: [[ [87, 123] ]] }, motion_active: { data: [[ [1, 123] ]] } }, + currentSettings: { torch: false, motion_detect: true }, + enabledSensors: ['battery_level', 'motion_active'], + enabledSettings: ['torch', 'motion_detect'], + availableSettings: {}, + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +tap.test('maps Android IP Webcam camera, sensors, binary sensor, and switches', async () => { + const devices = AndroidIpWebcamMapper.toDevices(snapshot); + const entities = AndroidIpWebcamMapper.toEntities(snapshot); + + expect(devices.length).toEqual(1); + expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'camera.kitchen_phone_camera')?.attributes?.mjpegUrl).toEqual('http://192.168.1.20:8080/video'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.battery_level')?.state).toEqual(87); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.motion_active')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.torch')?.state).toEqual('off'); +}); + +tap.test('models camera and setting services as explicit commands', async () => { + const streamCommand = AndroidIpWebcamMapper.commandForService(snapshot, { + domain: 'camera', + service: 'stream_source', + target: { entityId: 'camera.kitchen_phone_camera' }, + }); + const snapshotCommand = AndroidIpWebcamMapper.commandForService(snapshot, { + domain: 'camera', + service: 'snapshot', + target: { entityId: 'camera.kitchen_phone_camera' }, + }); + const torchCommand = AndroidIpWebcamMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.torch' }, + }); + const zoomCommand = AndroidIpWebcamMapper.commandForService(snapshot, { + domain: 'android_ip_webcam', + service: 'set_zoom', + target: {}, + data: { zoom: '42' }, + }); + + expect(streamCommand?.type).toEqual('stream_source'); + expect(snapshotCommand?.type).toEqual('snapshot_image'); + expect(torchCommand?.type).toEqual('torch'); + expect(torchCommand?.activate).toBeTrue(); + expect(zoomCommand?.type).toEqual('set_zoom'); + expect(zoomCommand?.zoom).toEqual(42); +}); + +export default tap.start(); diff --git a/test/apcupsd/test.apcupsd.client.node.ts b/test/apcupsd/test.apcupsd.client.node.ts new file mode 100644 index 0000000..9545b7e --- /dev/null +++ b/test/apcupsd/test.apcupsd.client.node.ts @@ -0,0 +1,45 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../../ts/plugins.js'; +import { ApcupsdClient } from '../../ts/integrations/apcupsd/index.js'; + +const statusText = `UPSNAME : TCP UPS +STATUS : ONBATT LOWBATT +BCHARGE : 17.0 Percent +STATFLAG : 0x05000000 +`; + +tap.test('requests APCUPSd NIS status over local TCP', async () => { + const server = plugins.net.createServer((socketArg) => { + socketArg.once('data', (chunkArg) => { + const buffer = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg); + const length = buffer.readUInt16BE(0); + const command = buffer.subarray(2, 2 + length).toString('utf8'); + expect(command).toEqual('status'); + socketArg.write(frame(statusText)); + socketArg.write(Buffer.alloc(2)); + }); + }); + + 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 ApcupsdClient({ host: '127.0.0.1', port, timeoutMs: 1000 }).getSnapshot(); + expect(snapshot.online).toBeTrue(); + expect(snapshot.ups.name).toEqual('TCP UPS'); + expect(snapshot.ups.lineOnline).toBeFalse(); + expect(snapshot.battery.chargePercent).toEqual(17); + } finally { + await new Promise((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())); + } +}); + +const frame = (valueArg: string): Buffer => { + const payload = Buffer.from(valueArg, 'utf8'); + const result = Buffer.alloc(payload.length + 2); + result.writeUInt16BE(payload.length, 0); + payload.copy(result, 2); + return result; +}; + +export default tap.start(); diff --git a/test/apcupsd/test.apcupsd.discovery.node.ts b/test/apcupsd/test.apcupsd.discovery.node.ts new file mode 100644 index 0000000..57f5f12 --- /dev/null +++ b/test/apcupsd/test.apcupsd.discovery.node.ts @@ -0,0 +1,25 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createApcupsdDiscoveryDescriptor } from '../../ts/integrations/apcupsd/index.js'; + +tap.test('matches and validates manual APCUPSd entries', async () => { + const descriptor = createApcupsdDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ host: '192.168.1.60', name: 'Rack APC UPS' }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('apcupsd'); + expect(result.candidate?.port).toEqual(3551); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.normalizedDeviceId).toEqual('192.168.1.60:3551'); +}); + +tap.test('rejects manual entries without APCUPSd hints', async () => { + const descriptor = createApcupsdDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ name: 'Generic device' }, {}); + expect(result.matched).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/apcupsd/test.apcupsd.mapper.node.ts b/test/apcupsd/test.apcupsd.mapper.node.ts new file mode 100644 index 0000000..6139c7e --- /dev/null +++ b/test/apcupsd/test.apcupsd.mapper.node.ts @@ -0,0 +1,61 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ApcupsdClient, ApcupsdMapper, type IApcupsdSnapshot } from '../../ts/integrations/apcupsd/index.js'; + +const rawStatus = `APC : 001,043,1036 +DATE : 2026-01-01 00:00:00 +0000 +HOSTNAME : nas +VERSION : 3.14.14 +UPSNAME : Office UPS +MODEL : Back-UPS ES 700 +STATUS : ONLINE +LINEV : 230.0 Volts +LOADPCT : 21.0 Percent +BCHARGE : 98.0 Percent +TIMELEFT : 42.5 Minutes +BATTV : 13.6 Volts +LINEFREQ : 50.0 Hz +OUTPUTV : 230.1 Volts +NOMPOWER : 405 Watts +SERIALNO : AS1234567890 +STATFLAG : 0x05000008 Status Flag +END APC : 2026-01-01 00:00:00 +0000 +`; + +tap.test('parses APCUPSd status output into a safe snapshot', async () => { + const snapshot = await new ApcupsdClient({ rawStatus }).getSnapshot(); + expect(snapshot.ups.name).toEqual('Office UPS'); + expect(snapshot.ups.serialNumber).toEqual('AS1234567890'); + expect(snapshot.ups.lineOnline).toBeTrue(); + expect(snapshot.battery.chargePercent).toEqual(98); + expect(snapshot.battery.timeLeftMinutes).toEqual(42.5); + expect(snapshot.power.lineVoltage).toEqual(230); + expect(snapshot.power.loadPercent).toEqual(21); +}); + +tap.test('maps APCUPSd snapshot to canonical devices and entities', async () => { + const snapshot = await new ApcupsdClient({ rawStatus }).getSnapshot(); + const devices = ApcupsdMapper.toDevices(snapshot); + const entities = ApcupsdMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'apcupsd.ups.as1234567890')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.office_ups_online_status')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_battery_charge')?.state).toEqual(98); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_input_voltage')?.attributes?.unit).toEqual('V'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_nominal_output_power')?.state).toEqual(405); +}); + +tap.test('maps offline snapshots without inventing unavailable sensor values', async () => { + const snapshot: IApcupsdSnapshot = { + ups: { id: 'offline-ups', name: 'Offline UPS' }, + battery: {}, + power: {}, + status: {}, + online: false, + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const entities = ApcupsdMapper.toEntities(snapshot); + expect(entities.find((entityArg) => entityArg.id === 'sensor.offline_ups_status')?.available).toBeFalse(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.offline_ups_battery_charge')).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/blebox/test.blebox.discovery.node.ts b/test/blebox/test.blebox.discovery.node.ts new file mode 100644 index 0000000..0ab037e --- /dev/null +++ b/test/blebox/test.blebox.discovery.node.ts @@ -0,0 +1,37 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createBleboxDiscoveryDescriptor } from '../../ts/integrations/blebox/index.js'; + +tap.test('matches BleBox zeroconf records and validates manual candidates', async () => { + const descriptor = createBleboxDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const mdnsResult = await mdnsMatcher.matches({ + name: 'blebox-1afe34e750b8', + type: '_bbxsrv._tcp.local.', + host: 'blebox.local', + port: 80, + txt: { + id: '1afe34e750b8', + type: 'switchBoxD', + deviceName: 'Kitchen Switch', + }, + }, {}); + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.normalizedDeviceId).toEqual('1afe34e750b8'); + expect(mdnsResult.candidate?.manufacturer).toEqual('BleBox'); + + const manualMatcher = descriptor.getMatchers()[1]; + const manualResult = await manualMatcher.matches({ host: '192.168.1.50' }, {}); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.port).toEqual(80); + + const validator = descriptor.getValidators()[0]; + const validResult = await validator.validate({ + source: 'manual', + integrationDomain: 'blebox', + host: '192.168.1.50', + port: 80, + }, {}); + expect(validResult.matched).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/blebox/test.blebox.mapper.node.ts b/test/blebox/test.blebox.mapper.node.ts new file mode 100644 index 0000000..b52b37f --- /dev/null +++ b/test/blebox/test.blebox.mapper.node.ts @@ -0,0 +1,94 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BleboxMapper } from '../../ts/integrations/blebox/index.js'; +import type { IBleboxSnapshot } from '../../ts/integrations/blebox/index.js'; + +tap.test('maps BleBox switch, power sensor, light, cover, and moisture snapshots', async () => { + const switchSnapshot: IBleboxSnapshot = { + device: { + id: '1afe34e750b8', + type: 'switchBoxD', + deviceName: 'Kitchen Switch', + fv: '0.200', + hv: '0.7', + apiLevel: 20200831, + }, + state: { + relays: [ + { relay: 0, state: 1, name: 'Counter' }, + { relay: 1, state: 0, name: 'Sink' }, + ], + sensors: [{ type: 'activePower', id: 0, value: 12.5 }], + }, + }; + const switchEntities = BleboxMapper.toEntities(switchSnapshot); + expect(switchEntities.find((entityArg) => entityArg.id === 'switch.kitchen_switch_relay_0')?.state).toEqual('on'); + expect(switchEntities.find((entityArg) => entityArg.id === 'sensor.kitchen_switch_activepower_0')?.state).toEqual(12.5); + + const lightSnapshot: IBleboxSnapshot = { + device: { + id: '2bee34e750b8', + type: 'wLightBox', + deviceName: 'Cabinet Light', + fv: '0.993', + hv: '4.3', + apiLevel: 20200229, + }, + extendedState: { + rgbw: { + desiredColor: 'fa00203a', + colorMode: 4, + effectID: 0, + effectsNames: { 0: 'NONE', 1: 'FADE' }, + }, + }, + }; + const lightEntity = BleboxMapper.toEntities(lightSnapshot)[0]; + expect(lightEntity.id).toEqual('light.cabinet_light_color'); + expect(lightEntity.attributes?.brightness).toEqual(250); + expect(lightEntity.attributes?.colorMode).toEqual('rgbw'); + + const coverSnapshot: IBleboxSnapshot = { + device: { + id: '3cee34e750b8', + type: 'shutterBox', + deviceName: 'Bedroom Shutter', + fv: '0.147', + hv: '0.7', + apiLevel: 20180604, + }, + state: { + shutter: { + state: 1, + desiredPos: { position: 25, tilt: 80 }, + }, + }, + }; + const coverEntity = BleboxMapper.toEntities(coverSnapshot)[0]; + expect(coverEntity.state).toEqual('opening'); + expect(coverEntity.attributes?.currentPosition).toEqual(75); + expect(coverEntity.attributes?.currentTiltPosition).toEqual(20); + + const sensorSnapshot: IBleboxSnapshot = { + device: { + id: '4dee34e750b8', + type: 'multiSensor', + deviceName: 'Garden Sensor', + fv: '1.0', + hv: '1.0', + apiLevel: 20230606, + }, + extendedState: { + multiSensor: { + sensors: [ + { id: 0, type: 'temperature', value: 2234 }, + { id: 1, type: 'flood', value: 1 }, + ], + }, + }, + }; + const sensorEntities = BleboxMapper.toEntities(sensorSnapshot); + expect(sensorEntities.find((entityArg) => entityArg.id === 'sensor.garden_sensor_temperature_0')?.state).toEqual(22.34); + expect(sensorEntities.find((entityArg) => entityArg.id === 'binary_sensor.garden_sensor_flood_1')?.state).toEqual('on'); +}); + +export default tap.start(); diff --git a/test/blebox/test.blebox.runtime.node.ts b/test/blebox/test.blebox.runtime.node.ts new file mode 100644 index 0000000..11ace09 --- /dev/null +++ b/test/blebox/test.blebox.runtime.node.ts @@ -0,0 +1,79 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BleboxIntegration } from '../../ts/integrations/blebox/index.js'; +import type { IBleboxSnapshot } from '../../ts/integrations/blebox/index.js'; + +const switchSnapshot: IBleboxSnapshot = { + device: { + id: '1afe34e750b8', + type: 'switchBoxD', + deviceName: 'Kitchen Switch', + fv: '0.200', + hv: '0.7', + apiLevel: 20200831, + }, + state: { + relays: [ + { relay: 0, state: 0, name: 'Counter' }, + { relay: 1, state: 0, name: 'Sink' }, + ], + }, +}; + +tap.test('runs safe BleBox switch commands through modeled local HTTP paths', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method?: string }> = []; + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + calls.push({ url: String(urlArg), method: initArg?.method }); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof globalThis.fetch; + + try { + const runtime = await new BleboxIntegration().setup({ host: '192.168.1.50', snapshot: switchSnapshot }, {}); + const result = await runtime.callService?.({ + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.kitchen_switch_relay_1' }, + }); + expect(result?.success).toBeTrue(); + expect(calls[0].url).toEqual('http://192.168.1.50/s/1/1'); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('rejects unsafe BleBox light service payloads before HTTP commands', async () => { + const runtime = await new BleboxIntegration().setup({ host: '192.168.1.50', snapshot: { + device: { + id: '2bee34e750b8', + type: 'wLightBox', + deviceName: 'Cabinet Light', + fv: '0.993', + hv: '4.3', + apiLevel: 20200229, + }, + extendedState: { rgbw: { desiredColor: 'fa00203a', colorMode: 4, effectID: 0 } }, + } }, {}); + const result = await runtime.callService?.({ + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.cabinet_light_color' }, + data: { brightness: 999 }, + }); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain('brightness'); + await runtime.destroy(); +}); + +tap.test('config flow returns a local HTTP config and validates credentials', async () => { + const integration = new BleboxIntegration(); + const step = await integration.configFlow.start({ source: 'manual', integrationDomain: 'blebox', host: '192.168.1.50' }, {}); + const incomplete = await step.submit?.({ host: '192.168.1.50', port: 80, username: 'admin' }); + expect(incomplete?.kind).toEqual('error'); + const done = await step.submit?.({ host: '192.168.1.50', port: 80, username: 'admin', password: 'secret' }); + expect(done?.kind).toEqual('done'); + expect(done?.config?.host).toEqual('192.168.1.50'); + expect(done?.config?.protocol).toEqual('http'); +}); + +export default tap.start(); diff --git a/test/broadlink/test.broadlink.discovery.node.ts b/test/broadlink/test.broadlink.discovery.node.ts new file mode 100644 index 0000000..5de04bd --- /dev/null +++ b/test/broadlink/test.broadlink.discovery.node.ts @@ -0,0 +1,56 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BroadlinkConfigFlow, createBroadlinkDiscoveryDescriptor } from '../../ts/integrations/broadlink/index.js'; + +tap.test('matches Broadlink DHCP candidates by Home Assistant MAC prefix', async () => { + const descriptor = createBroadlinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'broadlink-dhcp-match'); + const result = await matcher?.matches({ + ipAddress: '192.168.1.50', + macAddress: '34:EA:34:B4:5D:2C', + hostname: 'rm4-bedroom', + }, {}); + expect(result?.matched).toBeTrue(); + expect(result?.candidate?.integrationDomain).toEqual('broadlink'); + expect(result?.candidate?.host).toEqual('192.168.1.50'); + expect(result?.candidate?.metadata?.discoveryProtocol).toEqual('dhcp'); +}); + +tap.test('matches manual Broadlink snapshot and learned-code entries', async () => { + const descriptor = createBroadlinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'broadlink-manual-match'); + const result = await matcher?.matches({ + host: '192.168.1.51', + type: 'RM4PRO', + macAddress: '34ea34b45d2d', + codes: { television: { power: 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=' } }, + switches: [{ name: 'Television', commandOn: 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=' }], + }, {}); + expect(result?.matched).toBeTrue(); + expect(result?.candidate?.metadata?.deviceType).toEqual('RM4PRO'); + expect(result?.candidate?.metadata?.codesConfigured).toBeTrue(); + expect(result?.candidate?.metadata?.customSwitchesConfigured).toBeTrue(); +}); + +tap.test('builds Broadlink config from candidate and rejects invalid snapshots', async () => { + const flow = new BroadlinkConfigFlow(); + const step = await flow.start({ + source: 'manual', + integrationDomain: 'broadlink', + id: '34:ea:34:b4:5d:2c', + host: '192.168.1.52', + macAddress: '34:ea:34:b4:5d:2c', + model: 'RM4PRO', + metadata: { deviceType: 'RM4PRO', devtype: 0x520b }, + }, {}); + + const done = await step.submit?.({ host: '192.168.1.52', timeout: 7, name: 'Bedroom RM4', type: 'RM4PRO' }); + expect(done?.kind).toEqual('done'); + expect(done?.config?.host).toEqual('192.168.1.52'); + expect(done?.config?.timeout).toEqual(7); + expect(done?.config?.type).toEqual('RM4PRO'); + + const invalid = await step.submit?.({ host: '192.168.1.52', snapshotJson: '{"connected":true}' }); + expect(invalid?.kind).toEqual('error'); +}); + +export default tap.start(); diff --git a/test/broadlink/test.broadlink.mapper.node.ts b/test/broadlink/test.broadlink.mapper.node.ts new file mode 100644 index 0000000..26fcb51 --- /dev/null +++ b/test/broadlink/test.broadlink.mapper.node.ts @@ -0,0 +1,138 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BroadlinkClient, BroadlinkIntegration, BroadlinkMapper } from '../../ts/integrations/broadlink/index.js'; +import type { IBroadlinkCommand, IBroadlinkSnapshot } from '../../ts/integrations/broadlink/index.js'; + +const irCode = 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA='; + +const snapshot: IBroadlinkSnapshot = { + connected: true, + host: '192.168.1.50', + port: 80, + events: [], + entities: [], + devices: [ + { + id: '34ea34b45d2c', + host: '192.168.1.50', + macAddress: '34:ea:34:b4:5d:2c', + type: 'RM4PRO', + name: 'Bedroom RM4 Pro', + model: 'RM4 pro', + available: true, + state: { temperature: 23.5, humidity: 45 }, + codes: { television: { power: irCode } }, + switches: [{ name: 'Bedroom TV', commandOn: irCode, commandOff: irCode }], + }, + { + id: 'desk-plug', + host: '192.168.1.60', + macAddress: '24:df:a7:00:00:01', + type: 'SP4B', + name: 'Desk Plug', + available: true, + state: { pwr: 1, power: 12.3, volt: 230.1, current: 0.11, totalconsum: 1.5 }, + }, + ], +}; + +tap.test('maps Broadlink snapshots to remote, switch, and sensor entities', async () => { + const entities = BroadlinkMapper.toEntities(snapshot); + expect(entities.find((entityArg) => entityArg.id === 'remote.bedroom_rm4_pro')?.platform).toEqual('remote'); + expect(entities.find((entityArg) => entityArg.id === 'remote.bedroom_rm4_pro')?.attributes?.supportsRf).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'switch.bedroom_tv')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'switch.desk_plug')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.bedroom_rm4_pro_temperature')?.state).toEqual(23.5); + expect(entities.find((entityArg) => entityArg.id === 'sensor.desk_plug_power')?.attributes?.unitOfMeasurement).toEqual('W'); + + const devices = BroadlinkMapper.toDevices(snapshot); + expect(devices.find((deviceArg) => deviceArg.id === 'broadlink.device.34ea34b45d2c')?.features.some((featureArg) => featureArg.id === 'remote')).toBeTrue(); + expect(devices.find((deviceArg) => deviceArg.id === 'broadlink.device.desk_plug')?.features.some((featureArg) => featureArg.id === 'power')).toBeTrue(); +}); + +tap.test('maps learned remote commands, raw IR, RF, and switch services', async () => { + const remoteCommand = BroadlinkMapper.commandForService(snapshot, { + domain: 'remote', + service: 'send_command', + target: { entityId: 'remote.bedroom_rm4_pro' }, + data: { device: 'television', command: 'power', num_repeats: 2 }, + }); + expect(remoteCommand?.method).toEqual('send_data'); + expect(remoteCommand?.packets?.[0].kind).toEqual('ir'); + expect(remoteCommand?.packets?.[0].firstByte).toEqual(0x26); + expect(remoteCommand?.numRepeats).toEqual(2); + + const irCommand = BroadlinkMapper.commandForService(snapshot, { + domain: 'infrared', + service: 'send_command', + target: { entityId: 'remote.bedroom_rm4_pro' }, + data: { rawTimings: [9000, -4500, 560, -560] }, + }); + expect(irCommand?.packet?.kind).toEqual('ir'); + expect(irCommand?.packet?.firstByte).toEqual(0x26); + + const rfCommand = BroadlinkMapper.commandForService(snapshot, { + domain: 'radio_frequency', + service: 'send_command', + target: { entityId: 'remote.bedroom_rm4_pro' }, + data: { frequency: 433_920_000, rawTimings: [300, -900, 300, -900], repeat_count: 3 }, + }); + expect(rfCommand?.packet?.kind).toEqual('rf433'); + expect(rfCommand?.packet?.firstByte).toEqual(0xb2); + expect(rfCommand?.packet?.repeatCount).toEqual(3); + + const switchCommand = BroadlinkMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.desk_plug' }, + data: {}, + }); + expect(switchCommand?.method).toEqual('set_state'); + expect(switchCommand?.payload?.pwr).toEqual(false); +}); + +tap.test('does not report live UDP success without an injected executor', async () => { + const integration = new BroadlinkIntegration(); + const runtime = await integration.setup({ snapshot }, {}); + const result = await runtime.callService?.({ + domain: 'remote', + service: 'send_command', + target: { entityId: 'remote.bedroom_rm4_pro' }, + data: { device: 'television', command: 'power' }, + }); + expect(result?.success).toBeFalse(); + expect(String(result?.error).includes('not implemented')).toBeTrue(); +}); + +tap.test('delegates mapped commands to injected Broadlink executor', async () => { + const commands: IBroadlinkCommand[] = []; + const integration = new BroadlinkIntegration(); + const runtime = await integration.setup({ + snapshot, + commandExecutor: async (commandArg) => { + commands.push(commandArg); + return { success: true, transmitted: true, data: { ok: true } }; + }, + }, {}); + + const result = await runtime.callService?.({ + domain: 'remote', + service: 'send_command', + target: { entityId: 'remote.bedroom_rm4_pro' }, + data: { device: 'television', command: 'power' }, + }); + expect(result?.success).toBeTrue(); + expect(commands[0].method).toEqual('send_data'); + expect(commands[0].packets?.[0].base64).toEqual(BroadlinkClient.packetFromBase64(irCode).base64); +}); + +tap.test('exposes native Broadlink IR and RF packet encoders', async () => { + const irPacket = BroadlinkClient.irPacketFromTimings([9000, -4500, 560, -560]); + expect(irPacket.firstByte).toEqual(0x26); + expect(irPacket.kind).toEqual('ir'); + + const rfPacket = BroadlinkClient.rfPacketFromTimings({ frequency: 315_000_000, timings: [300, -900], repeatCount: 1 }); + expect(rfPacket.firstByte).toEqual(0xb4); + expect(rfPacket.kind).toEqual('rf315'); +}); + +export default tap.start(); diff --git a/test/dsmr/test.dsmr.discovery.node.ts b/test/dsmr/test.dsmr.discovery.node.ts new file mode 100644 index 0000000..dbbae00 --- /dev/null +++ b/test/dsmr/test.dsmr.discovery.node.ts @@ -0,0 +1,39 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DsmrConfigFlow, createDsmrDiscoveryDescriptor } from '../../ts/integrations/dsmr/index.js'; + +tap.test('matches manual DSMR network entries', async () => { + const descriptor = createDsmrDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ host: 'p1-reader.local', port: 2001, name: 'DSMR P1 bridge', metadata: { dsmr: true, dsmrVersion: '5' } }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('dsmr'); + expect(result.candidate?.metadata?.connectionType).toEqual('network'); + expect(result.candidate?.metadata?.liveValidation).toBeFalse(); +}); + +tap.test('matches manual DSMR serial entries and validates candidates', async () => { + const descriptor = createDsmrDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ serialPort: '/dev/ttyUSB0', name: 'DSMR P1 cable', metadata: { p1: true } }, {}); + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.metadata?.connectionType).toEqual('serial'); + expect(validation.matched).toBeTrue(); + expect(validation.reason).toContain('live communication is not assumed'); +}); + +tap.test('config flow creates network config without claiming connection success', async () => { + const flow = new DsmrConfigFlow(); + const step = await flow.start({ source: 'manual', id: 'p1-reader', host: 'p1-reader.local', port: 2001, metadata: { connectionType: 'network', dsmrVersion: '5' } }, {}); + const result = await step.submit!({ connectionType: 'network', host: 'p1-reader.local', port: 2001, dsmrVersion: '5', protocol: 'dsmr_protocol', liveRead: false }); + + expect(result.kind).toEqual('done'); + expect(result.config?.connectionType).toEqual('network'); + expect(result.config?.connected).toBeFalse(); + expect(result.config?.liveRead).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/dsmr/test.dsmr.mapper.node.ts b/test/dsmr/test.dsmr.mapper.node.ts new file mode 100644 index 0000000..0531644 --- /dev/null +++ b/test/dsmr/test.dsmr.mapper.node.ts @@ -0,0 +1,73 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DsmrClient, DsmrMapper, DsmrTelegramParser, type IDsmrConfig } from '../../ts/integrations/dsmr/index.js'; + +const sampleTelegram = `/ISk5\\2MT382-1000 +1-3:0.2.8(50) +0-0:1.0.0(240101120000W) +0-0:96.1.1(453030333630303337383931323334) +1-0:1.8.1(00123.456*kWh) +1-0:1.8.2(00234.567*kWh) +1-0:2.8.1(00012.345*kWh) +1-0:2.8.2(00023.456*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(01.193*kW) +1-0:2.7.0(00.000*kW) +1-0:21.7.0(00.378*kW) +1-0:41.7.0(00.400*kW) +1-0:61.7.0(00.415*kW) +0-1:24.1.0(003) +0-1:96.1.0(473030333930303137) +0-1:24.2.1(240101110000W)(00024.123*m3) +!ABCD`; + +tap.test('parses DSMR telegrams and maps energy gas and power sensors', async () => { + const snapshot = DsmrTelegramParser.parseTelegram(sampleTelegram, { config: { dsmrVersion: '5', connectionType: 'serial', serialPort: '/dev/ttyUSB0' } }); + const entities = DsmrMapper.toEntities(snapshot); + const devices = DsmrMapper.toDevices(snapshot); + + expect(snapshot.connected).toBeTrue(); + expect(snapshot.telegram?.objects.some((objectArg) => objectArg.obisCode === '1-0:1.7.0')).toBeTrue(); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'current_electricity_usage')?.value).toEqual(1.193); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'electricity_used_tariff_1')?.value).toEqual(123.456); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'electricity_active_tariff')?.value).toEqual('normal'); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'hourly_gas_meter_reading')?.value).toEqual(24.123); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'current_electricity_usage')?.attributes?.unitOfMeasurement).toEqual('kW'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'hourly_gas_meter_reading')?.deviceId).toContain('dsmr.gas'); + expect(devices.some((deviceArg) => deviceArg.id.startsWith('dsmr.electricity.'))).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id.startsWith('dsmr.gas.'))).toBeTrue(); +}); + +tap.test('maps status snapshots without raw telegrams', async () => { + const config: IDsmrConfig = { + id: 'meter-status', + dsmrVersion: '5', + status: { + meter: { serialId: 'E123', serialIdGas: 'G123' }, + values: { + current_electricity_usage: { value: 0.456, unit: 'kW' }, + electricity_used_tariff_1: { value: 12.3, unit: 'kWh' }, + hourly_gas_meter_reading: { value: 4.2, unit: 'm3' }, + }, + }, + }; + const snapshot = await new DsmrClient(config).getSnapshot(); + const entities = DsmrMapper.toEntities(snapshot); + + expect(snapshot.source).toEqual('status'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'current_electricity_usage')?.state).toEqual(0.456); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'electricity_used_tariff_1')?.attributes?.stateClass).toEqual('total_increasing'); + expect(entities.find((entityArg) => entityArg.attributes?.key === 'hourly_gas_meter_reading')?.state).toEqual(4.2); +}); + +tap.test('does not fake live serial refresh success without a telegram source', async () => { + const client = new DsmrClient({ connectionType: 'serial', serialPort: '/dev/ttyUSB0', port: '/dev/ttyUSB0', dsmrVersion: '5' }); + const snapshot = await client.getSnapshot(); + const result = await client.refresh(); + + expect(snapshot.connected).toBeFalse(); + expect(snapshot.sensors.length).toEqual(0); + expect(result.success).toBeFalse(); + expect(result.error).toContain('telegramProvider'); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index bbf5808..e76f9ce 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,13 +3,19 @@ export * from './protocols/index.js'; export * from './integrations/index.js'; import { HueIntegration } from './integrations/hue/index.js'; +import { AirgradientIntegration } from './integrations/airgradient/index.js'; +import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js'; import { AndroidtvIntegration } from './integrations/androidtv/index.js'; import { AxisIntegration } from './integrations/axis/index.js'; +import { ApcupsdIntegration } from './integrations/apcupsd/index.js'; +import { BleboxIntegration } from './integrations/blebox/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 { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js'; +import { DsmrIntegration } from './integrations/dsmr/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; import { HomematicIntegration } from './integrations/homematic/index.js'; @@ -47,13 +53,19 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated import { IntegrationRegistry } from './core/index.js'; export const integrations = [ + new AirgradientIntegration(), + new AndroidIpWebcamIntegration(), new AndroidtvIntegration(), + new ApcupsdIntegration(), new AxisIntegration(), + new BleboxIntegration(), new BraviatvIntegration(), + new BroadlinkIntegration(), new CastIntegration(), new DeconzIntegration(), new DenonavrIntegration(), new DlnaDmrIntegration(), + new DsmrIntegration(), new EsphomeIntegration(), new HomekitControllerIntegration(), new HomematicIntegration(), diff --git a/ts/integrations/airgradient/.generated-by-smarthome-exchange b/ts/integrations/airgradient/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/airgradient/.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/airgradient/airgradient.classes.client.ts b/ts/integrations/airgradient/airgradient.classes.client.ts new file mode 100644 index 0000000..3ef6fb7 --- /dev/null +++ b/ts/integrations/airgradient/airgradient.classes.client.ts @@ -0,0 +1,581 @@ +import type { + IAirgradientCommand, + IAirgradientCommandResult, + IAirgradientConfig, + IAirgradientDevice, + IAirgradientDeviceConfig, + IAirgradientDeviceInfo, + IAirgradientEvent, + IAirgradientMeasures, + IAirgradientSnapshot, + TAirgradientConfigField, + TAirgradientConfigurationControl, + TAirgradientLedBarMode, + TAirgradientPmStandard, + TAirgradientProtocol, + TAirgradientTemperatureUnit, +} from './airgradient.types.js'; +import { airgradientDefaultPort } from './airgradient.types.js'; + +const airgradientManufacturer = 'AirGradient'; +const defaultTimeoutMs = 10000; + +type TAirgradientEventHandler = (eventArg: IAirgradientEvent) => void; + +export class AirgradientApiError extends Error { + constructor(messageArg: string, public readonly status?: number) { + super(messageArg); + this.name = 'AirgradientApiError'; + } +} + +export class AirgradientParseError extends Error { + constructor(messageArg: string) { + super(messageArg); + this.name = 'AirgradientParseError'; + } +} + +export class AirgradientClient { + private currentSnapshot?: IAirgradientSnapshot; + private readonly events: IAirgradientEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IAirgradientConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot)); + return this.currentSnapshot; + } + + if (this.config.host) { + try { + this.currentSnapshot = this.normalizeSnapshot(await this.fetchSnapshot()); + return this.currentSnapshot; + } catch (errorArg) { + this.emit({ type: 'error', data: { message: this.errorMessage(errorArg) }, timestamp: Date.now() }); + this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false)); + return this.currentSnapshot; + } + } + + this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected === true)); + return this.currentSnapshot; + } + + public onEvent(handlerArg: TAirgradientEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IAirgradientCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, timestamp: Date.now(), deviceId: this.commandDeviceId(commandArg), entityId: this.commandEntityId(commandArg) }); + + try { + const result = await this.executeCommand(commandArg); + this.emit({ + type: result.success ? 'command_executed' : 'command_failed', + command: commandArg, + data: result, + timestamp: Date.now(), + deviceId: this.commandDeviceId(commandArg), + entityId: this.commandEntityId(commandArg), + }); + return result; + } catch (errorArg) { + const result = { success: false, error: this.errorMessage(errorArg), data: { command: commandArg } }; + this.emit({ type: 'command_failed', command: commandArg, data: result, timestamp: Date.now(), deviceId: this.commandDeviceId(commandArg), entityId: this.commandEntityId(commandArg) }); + return result; + } + } + + public async refresh(): Promise { + this.currentSnapshot = undefined; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() }); + return snapshot; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async executeCommand(commandArg: IAirgradientCommand): Promise { + if (this.config.commandExecutor) { + return this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + } + + if (commandArg.type === 'refresh') { + return { success: true, data: await this.refresh() }; + } + + if (!this.config.host) { + return { + success: false, + error: 'AirGradient live local configuration requires config.host, or provide commandExecutor for snapshot/manual configs.', + data: { command: commandArg }, + }; + } + + await this.putConfig(commandArg.payload); + this.patchConfig(commandArg.field, commandArg.value); + return { success: true, data: { field: commandArg.field, value: commandArg.value } }; + } + + private async fetchSnapshot(): Promise { + const measures = await this.getCurrentMeasures(); + const deviceConfig = await this.getDeviceConfig(); + const latestFirmwareVersion = this.config.checkFirmwareUpdate && measures.serialNumber + ? await this.getLatestFirmwareVersion(measures.serialNumber).catch(() => undefined) + : this.config.latestFirmwareVersion; + + const device: IAirgradientDevice = { + id: this.config.uniqueId || measures.serialNumber || this.config.host, + host: this.config.host, + port: this.config.port || airgradientDefaultPort, + protocol: this.protocol(), + name: this.config.name || this.config.deviceInfo?.name || this.deviceName(measures, this.config.deviceInfo), + manufacturer: airgradientManufacturer, + model: this.config.model || measures.model, + modelName: modelName(measures.model) || this.config.deviceInfo?.modelName, + serialNumber: measures.serialNumber || this.config.serialNumber, + firmwareVersion: measures.firmwareVersion || this.config.firmwareVersion, + latestFirmwareVersion, + online: true, + measures, + config: deviceConfig, + metadata: { + ...this.config.deviceInfo, + source: 'local-http', + }, + }; + return { + connected: true, + updatedAt: new Date().toISOString(), + devices: [device, ...this.normalizeConfiguredDevices(true)], + events: [...(this.config.events || []), ...this.events], + }; + } + + private snapshotFromConfig(connectedArg: boolean): IAirgradientSnapshot { + const devices = this.normalizeConfiguredDevices(connectedArg); + if (!devices.length && (this.config.host || this.config.measures || this.config.deviceConfig || this.config.deviceInfo || this.config.serialNumber || this.config.model || this.config.name)) { + devices.push({ + id: this.config.uniqueId || this.config.serialNumber || this.config.host, + host: this.config.host, + port: this.config.port || airgradientDefaultPort, + protocol: this.protocol(), + name: this.config.name || this.config.deviceInfo?.name, + manufacturer: airgradientManufacturer, + model: this.config.model || this.config.deviceInfo?.model || this.config.measures?.model, + modelName: this.config.deviceInfo?.modelName, + serialNumber: this.config.serialNumber || this.config.deviceInfo?.serialNumber || this.config.measures?.serialNumber, + firmwareVersion: this.config.firmwareVersion || this.config.deviceInfo?.firmwareVersion || this.config.measures?.firmwareVersion, + latestFirmwareVersion: this.config.latestFirmwareVersion || this.config.deviceInfo?.latestFirmwareVersion, + online: connectedArg, + measures: this.config.measures, + config: this.config.deviceConfig, + metadata: this.config.deviceInfo, + }); + } + return { + connected: connectedArg, + updatedAt: new Date().toISOString(), + devices, + events: [...(this.config.events || []), ...this.events], + }; + } + + private normalizeConfiguredDevices(connectedArg: boolean): IAirgradientDevice[] { + const devices: IAirgradientDevice[] = []; + for (const device of this.config.devices || []) { + devices.push({ online: connectedArg, manufacturer: airgradientManufacturer, ...device }); + } + for (const entry of this.config.manualEntries || []) { + if (entry.snapshot) { + devices.push(...this.normalizeSnapshot(entry.snapshot).devices); + } else { + devices.push({ + id: entry.id || entry.serialNumber || entry.host, + host: entry.host, + port: entry.port || airgradientDefaultPort, + protocol: entry.protocol || this.protocol(), + name: entry.name, + manufacturer: entry.manufacturer || airgradientManufacturer, + model: entry.model || entry.measures?.model, + serialNumber: entry.serialNumber || entry.measures?.serialNumber, + firmwareVersion: entry.firmwareVersion || entry.measures?.firmwareVersion, + latestFirmwareVersion: entry.latestFirmwareVersion, + online: this.booleanValue(entry.metadata?.connected) ?? connectedArg, + measures: entry.measures, + config: entry.deviceConfig, + metadata: entry.metadata, + }); + } + } + return devices; + } + + private normalizeSnapshot(snapshotArg: IAirgradientSnapshot): IAirgradientSnapshot { + const devices = snapshotArg.devices.map((deviceArg) => this.normalizeDevice(deviceArg, snapshotArg.connected)); + return { + ...snapshotArg, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + connected: Boolean(snapshotArg.connected), + devices, + events: snapshotArg.events || [], + }; + } + + private normalizeDevice(deviceArg: IAirgradientDevice, connectedArg: boolean): IAirgradientDevice { + const measures = deviceArg.measures ? normalizeMeasures(deviceArg.measures) : undefined; + const deviceConfig = deviceArg.config ? normalizeDeviceConfig(deviceArg.config) : undefined; + const serialNumber = deviceArg.serialNumber || measures?.serialNumber; + const model = deviceArg.model || measures?.model; + const firmwareVersion = deviceArg.firmwareVersion || measures?.firmwareVersion; + return { + ...deviceArg, + id: deviceArg.id || serialNumber || deviceArg.host || deviceArg.name, + host: deviceArg.host || this.config.host, + port: deviceArg.port || this.config.port || airgradientDefaultPort, + protocol: deviceArg.protocol || this.protocol(), + name: deviceArg.name || this.deviceName(measures, deviceArg), + manufacturer: deviceArg.manufacturer || airgradientManufacturer, + model, + modelName: deviceArg.modelName || modelName(model), + serialNumber, + firmwareVersion, + latestFirmwareVersion: deviceArg.latestFirmwareVersion || this.config.latestFirmwareVersion, + online: deviceArg.online ?? connectedArg, + measures, + config: deviceConfig, + }; + } + + private async getCurrentMeasures(): Promise { + const raw = await this.getJson>('measures/current'); + return parseMeasures(raw); + } + + private async getDeviceConfig(): Promise { + const raw = await this.getJson>('config'); + return parseDeviceConfig(raw); + } + + private async putConfig(payloadArg: Partial>): Promise { + await this.requestJson('config', { method: 'PUT', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(payloadArg) }); + } + + private async getLatestFirmwareVersion(serialNumberArg: string): Promise { + const url = `http://hw.airgradient.com/sensors/airgradient:${encodeURIComponent(serialNumberArg)}/generic/os/firmware`; + const response = await this.fetchWithTimeout(url, { method: 'GET', headers: { accept: 'application/json' } }); + const raw = await this.jsonResponse>(response, url); + const targetVersion = stringValue(raw.targetVersion); + if (!targetVersion) { + throw new AirgradientParseError('AirGradient firmware version response is missing targetVersion.'); + } + return targetVersion; + } + + private async getJson(pathArg: string): Promise { + return this.requestJson(pathArg, { method: 'GET', headers: { accept: 'application/json' } }); + } + + private async requestJson(pathArg: string, initArg: RequestInit): Promise { + const url = this.url(pathArg); + const response = await this.fetchWithTimeout(url, initArg); + return this.jsonResponse(response, url); + } + + private async jsonResponse(responseArg: Response, urlArg: string): Promise { + const text = await responseArg.text(); + if (responseArg.status !== 200) { + throw new AirgradientApiError(`AirGradient request ${urlArg} failed with HTTP ${responseArg.status}: ${text}`, responseArg.status); + } + try { + return JSON.parse(text) as TValue; + } catch (errorArg) { + throw new AirgradientParseError(`AirGradient response from ${urlArg} is not valid JSON: ${this.errorMessage(errorArg)}`); + } + } + + private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise { + const abortController = new AbortController(); + const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs); + try { + return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal }); + } catch (errorArg) { + throw new AirgradientApiError(`Error occurred while communicating with AirGradient: ${this.errorMessage(errorArg)}`); + } finally { + globalThis.clearTimeout(timeout); + } + } + + private patchConfig(fieldArg: TAirgradientConfigField, valueArg: unknown): void { + const deviceConfig = this.currentSnapshot?.devices[0]?.config; + if (!deviceConfig) { + return; + } + if (fieldArg === 'abcDays') { + deviceConfig.co2AutomaticBaselineCalibrationDays = typeof valueArg === 'number' ? valueArg : deviceConfig.co2AutomaticBaselineCalibrationDays; + } else if (fieldArg === 'pmStandard') { + deviceConfig.pmStandard = pmStandardValue(valueArg) || deviceConfig.pmStandard; + } else if (fieldArg === 'temperatureUnit') { + deviceConfig.temperatureUnit = temperatureUnitValue(valueArg) || deviceConfig.temperatureUnit; + } else if (fieldArg === 'configurationControl') { + deviceConfig.configurationControl = configurationControlValue(valueArg) || deviceConfig.configurationControl; + } else if (fieldArg === 'ledBarMode') { + deviceConfig.ledBarMode = ledBarModeValue(valueArg) || deviceConfig.ledBarMode; + } else if (fieldArg === 'displayBrightness') { + deviceConfig.displayBrightness = typeof valueArg === 'number' ? valueArg : deviceConfig.displayBrightness; + } else if (fieldArg === 'ledBarBrightness') { + deviceConfig.ledBarBrightness = typeof valueArg === 'number' ? valueArg : deviceConfig.ledBarBrightness; + } else if (fieldArg === 'postDataToAirGradient') { + deviceConfig.postDataToAirGradient = typeof valueArg === 'boolean' ? valueArg : deviceConfig.postDataToAirGradient; + } else if (fieldArg === 'noxLearningOffset') { + deviceConfig.noxLearningOffset = typeof valueArg === 'number' ? valueArg : deviceConfig.noxLearningOffset; + } else if (fieldArg === 'tvocLearningOffset') { + deviceConfig.tvocLearningOffset = typeof valueArg === 'number' ? valueArg : deviceConfig.tvocLearningOffset; + } + } + + private commandResult(resultArg: unknown, commandArg: IAirgradientCommand): IAirgradientCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IAirgradientCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private commandDeviceId(commandArg: IAirgradientCommand): string | undefined { + return commandArg.type === 'set_config' ? commandArg.deviceId : undefined; + } + + private commandEntityId(commandArg: IAirgradientCommand): string | undefined { + return commandArg.type === 'set_config' ? commandArg.entityId : undefined; + } + + private emit(eventArg: IAirgradientEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private url(pathArg: string): string { + const normalizedPath = pathArg.replace(/^\/+/, ''); + const port = this.config.port && this.config.port !== airgradientDefaultPort ? `:${this.config.port}` : ''; + return `${this.protocol()}://${this.hostWithoutScheme()}${port}/${normalizedPath}`; + } + + private protocol(): TAirgradientProtocol { + return this.config.protocol || 'http'; + } + + private hostWithoutScheme(): string { + const value = (this.config.host || '').trim().replace(/\/$/, ''); + try { + return new URL(value).host; + } catch { + return value.replace(/^https?:\/\//i, ''); + } + } + + private deviceName(measuresArg?: IAirgradientMeasures, infoArg?: IAirgradientDeviceInfo): string { + const model = modelName(measuresArg?.model || infoArg?.model) || measuresArg?.model || infoArg?.model || 'Monitor'; + return this.config.name || infoArg?.name || `${airgradientManufacturer} ${model}`; + } + + private cloneSnapshot(snapshotArg: IAirgradientSnapshot): IAirgradientSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IAirgradientSnapshot; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private errorMessage(errorArg: unknown): string { + return errorArg instanceof Error ? errorArg.message : String(errorArg); + } +} + +const parseMeasures = (rawArg: Record): IAirgradientMeasures => { + const signalStrength = requiredNumber(rawArg, 'wifi'); + const serialNumber = requiredString(rawArg, 'serialno'); + const bootTime = requiredNumber(rawArg, 'bootCount'); + const firmwareVersion = requiredString(rawArg, 'firmware'); + const model = requiredString(rawArg, 'model'); + return normalizeMeasures({ + signalStrength, + serialNumber, + bootTime, + firmwareVersion, + model, + rco2: nullableNumber(rawArg.rco2), + pm01: nullableNumber(rawArg.pm01), + pm02: nullableNumber(rawArg.pm02Compensated) ?? nullableNumber(rawArg.pm02), + rawPm02: nullableNumber(rawArg.pm02), + compensatedPm02: nullableNumber(rawArg.pm02Compensated), + pm10: nullableNumber(rawArg.pm10), + totalVolatileOrganicComponentIndex: nullableNumber(rawArg.tvocIndex), + rawTotalVolatileOrganicComponent: nullableNumber(rawArg.tvocRaw), + pm003Count: nullableNumber(rawArg.pm003Count), + nitrogenIndex: nullableNumber(rawArg.noxIndex), + rawNitrogen: nullableNumber(rawArg.noxRaw), + ambientTemperature: nullableNumber(rawArg.atmpCompensated) ?? nullableNumber(rawArg.atmp), + rawAmbientTemperature: nullableNumber(rawArg.atmp), + compensatedAmbientTemperature: nullableNumber(rawArg.atmpCompensated), + relativeHumidity: nullableNumber(rawArg.rhumCompensated) ?? nullableNumber(rawArg.rhum), + rawRelativeHumidity: nullableNumber(rawArg.rhum), + compensatedRelativeHumidity: nullableNumber(rawArg.rhumCompensated), + raw: rawArg, + }); +}; + +const parseDeviceConfig = (rawArg: Record): IAirgradientDeviceConfig => { + const config = normalizeDeviceConfig({ + country: requiredString(rawArg, 'country'), + pmStandard: pmStandardValue(requiredString(rawArg, 'pmStandard')), + ledBarMode: ledBarModeValue(requiredString(rawArg, 'ledBarMode')), + co2AutomaticBaselineCalibrationDays: requiredNumber(rawArg, 'abcDays'), + temperatureUnit: temperatureUnitValue(requiredString(rawArg, 'temperatureUnit')), + configurationControl: configurationControlValue(requiredString(rawArg, 'configurationControl')), + postDataToAirGradient: requiredBoolean(rawArg, 'postDataToAirGradient'), + ledBarBrightness: requiredNumber(rawArg, 'ledBarBrightness'), + displayBrightness: requiredNumber(rawArg, 'displayBrightness'), + noxLearningOffset: requiredNumber(rawArg, 'noxLearningOffset'), + tvocLearningOffset: requiredNumber(rawArg, 'tvocLearningOffset'), + raw: rawArg, + }); + return config; +}; + +export const normalizeMeasures = (measuresArg: IAirgradientMeasures): IAirgradientMeasures => { + const raw = measuresArg as Record; + const compensatedPm02 = numberAlias(raw, 'compensatedPm02', 'pm02Compensated'); + const compensatedAmbientTemperature = numberAlias(raw, 'compensatedAmbientTemperature', 'atmpCompensated'); + const compensatedRelativeHumidity = numberAlias(raw, 'compensatedRelativeHumidity', 'rhumCompensated'); + return { + ...measuresArg, + signalStrength: numberAlias(raw, 'signalStrength', 'wifi'), + serialNumber: stringAlias(raw, 'serialNumber', 'serialno', 'serial_number'), + bootTime: numberAlias(raw, 'bootTime', 'bootCount'), + firmwareVersion: stringAlias(raw, 'firmwareVersion', 'firmware'), + model: stringAlias(raw, 'model'), + rco2: nullableNumberAlias(raw, 'rco2'), + pm01: nullableNumberAlias(raw, 'pm01'), + pm02: compensatedPm02 ?? nullableNumberAlias(raw, 'pm02'), + rawPm02: nullableNumberAlias(raw, 'rawPm02', 'pm02'), + compensatedPm02, + pm10: nullableNumberAlias(raw, 'pm10'), + totalVolatileOrganicComponentIndex: nullableNumberAlias(raw, 'totalVolatileOrganicComponentIndex', 'tvocIndex'), + rawTotalVolatileOrganicComponent: nullableNumberAlias(raw, 'rawTotalVolatileOrganicComponent', 'tvocRaw'), + pm003Count: nullableNumberAlias(raw, 'pm003Count'), + nitrogenIndex: nullableNumberAlias(raw, 'nitrogenIndex', 'noxIndex'), + rawNitrogen: nullableNumberAlias(raw, 'rawNitrogen', 'noxRaw'), + ambientTemperature: compensatedAmbientTemperature ?? nullableNumberAlias(raw, 'ambientTemperature', 'atmp'), + rawAmbientTemperature: nullableNumberAlias(raw, 'rawAmbientTemperature', 'atmp'), + compensatedAmbientTemperature, + relativeHumidity: compensatedRelativeHumidity ?? nullableNumberAlias(raw, 'relativeHumidity', 'rhum'), + rawRelativeHumidity: nullableNumberAlias(raw, 'rawRelativeHumidity', 'rhum'), + compensatedRelativeHumidity, + }; +}; + +export const normalizeDeviceConfig = (configArg: IAirgradientDeviceConfig): IAirgradientDeviceConfig => { + const raw = configArg as Record; + return { + ...configArg, + country: stringAlias(raw, 'country'), + pmStandard: pmStandardValue(valueAlias(raw, 'pmStandard')), + ledBarMode: ledBarModeValue(valueAlias(raw, 'ledBarMode')), + co2AutomaticBaselineCalibrationDays: numberAlias(raw, 'co2AutomaticBaselineCalibrationDays', 'abcDays'), + temperatureUnit: temperatureUnitValue(valueAlias(raw, 'temperatureUnit')), + configurationControl: configurationControlValue(valueAlias(raw, 'configurationControl')), + postDataToAirGradient: booleanAlias(raw, 'postDataToAirGradient'), + ledBarBrightness: numberAlias(raw, 'ledBarBrightness'), + displayBrightness: numberAlias(raw, 'displayBrightness'), + noxLearningOffset: numberAlias(raw, 'noxLearningOffset'), + tvocLearningOffset: numberAlias(raw, 'tvocLearningOffset'), + }; +}; + +export const modelName = (modelArg?: string): string | undefined => { + if (!modelArg) { + return undefined; + } + if (modelArg.startsWith('I-9PSL')) { + return 'AirGradient ONE'; + } + if (modelArg.startsWith('O-1')) { + return 'AirGradient Open Air'; + } + if (modelArg.includes('DIY')) { + return 'AirGradient DIY'; + } + return undefined; +}; + +const requiredString = (recordArg: Record, keyArg: string): string => { + const value = stringValue(recordArg[keyArg]); + if (!value) { + throw new AirgradientParseError(`AirGradient response is missing required string field ${keyArg}.`); + } + return value; +}; + +const requiredNumber = (recordArg: Record, keyArg: string): number => { + const value = numberValue(recordArg[keyArg]); + if (value === undefined) { + throw new AirgradientParseError(`AirGradient response is missing required number field ${keyArg}.`); + } + return value; +}; + +const requiredBoolean = (recordArg: Record, keyArg: string): boolean => { + const value = recordArg[keyArg]; + if (typeof value !== 'boolean') { + throw new AirgradientParseError(`AirGradient response is missing required boolean field ${keyArg}.`); + } + return value; +}; + +const valueAlias = (recordArg: Record, ...keysArg: string[]): unknown => { + for (const key of keysArg) { + if (recordArg[key] !== undefined) { + return recordArg[key]; + } + } + return undefined; +}; + +const stringAlias = (recordArg: Record, ...keysArg: string[]): string | undefined => stringValue(valueAlias(recordArg, ...keysArg)); +const numberAlias = (recordArg: Record, ...keysArg: string[]): number | undefined => numberValue(valueAlias(recordArg, ...keysArg)); +const booleanAlias = (recordArg: Record, ...keysArg: string[]): boolean | undefined => { + const value = valueAlias(recordArg, ...keysArg); + return typeof value === 'boolean' ? value : undefined; +}; +const nullableNumberAlias = (recordArg: Record, ...keysArg: string[]): number | null | undefined => nullableNumber(valueAlias(recordArg, ...keysArg)); + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; +}; + +const nullableNumber = (valueArg: unknown): number | null | undefined => valueArg === null ? null : numberValue(valueArg); + +const pmStandardValue = (valueArg: unknown): TAirgradientPmStandard | undefined => valueArg === 'ugm3' || valueArg === 'us-aqi' ? valueArg : undefined; +const temperatureUnitValue = (valueArg: unknown): TAirgradientTemperatureUnit | undefined => valueArg === 'c' || valueArg === 'f' ? valueArg : undefined; +const configurationControlValue = (valueArg: unknown): TAirgradientConfigurationControl | undefined => valueArg === 'cloud' || valueArg === 'local' || valueArg === 'both' ? valueArg : undefined; +const ledBarModeValue = (valueArg: unknown): TAirgradientLedBarMode | undefined => valueArg === 'off' || valueArg === 'co2' || valueArg === 'pm' ? valueArg : undefined; diff --git a/ts/integrations/airgradient/airgradient.classes.configflow.ts b/ts/integrations/airgradient/airgradient.classes.configflow.ts new file mode 100644 index 0000000..0fe1ed9 --- /dev/null +++ b/ts/integrations/airgradient/airgradient.classes.configflow.ts @@ -0,0 +1,108 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IAirgradientConfig, IAirgradientDeviceConfig, IAirgradientMeasures, TAirgradientProtocol } from './airgradient.types.js'; +import { airgradientDefaultPort, airgradientMinFirmwareVersion } from './airgradient.types.js'; +import { versionAtLeast } from './airgradient.discovery.js'; + +export class AirgradientConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaults = this.defaultsFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Connect AirGradient', + description: 'Configure the local AirGradient HTTP endpoint. Supported firmware starts at 3.1.1.', + fields: [ + { name: 'host', label: 'Host or IP address', type: 'text', required: true }, + { name: 'port', label: 'HTTP port', type: 'number' }, + { name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] }, + { name: 'name', label: 'Device name', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || defaults.host; + if (!host) { + return { kind: 'error', title: 'Invalid AirGradient config', error: 'AirGradient setup requires a host or IP address.' }; + } + if (defaults.firmwareVersion && !versionAtLeast(defaults.firmwareVersion, airgradientMinFirmwareVersion)) { + return { kind: 'error', title: 'Unsupported AirGradient firmware', error: `AirGradient firmware ${defaults.firmwareVersion} is below the supported minimum ${airgradientMinFirmwareVersion}.` }; + } + const protocol = this.protocolValue(valuesArg.protocol) || defaults.protocol || 'http'; + const port = this.numberValue(valuesArg.port) || defaults.port || airgradientDefaultPort; + const name = this.stringValue(valuesArg.name) || defaults.name; + return { + kind: 'done', + title: 'AirGradient configured', + config: { + host, + port, + protocol, + name, + uniqueId: defaults.id, + serialNumber: defaults.serialNumber, + model: defaults.model, + firmwareVersion: defaults.firmwareVersion, + measures: defaults.measures, + deviceConfig: defaults.deviceConfig, + deviceInfo: { + host, + port, + protocol, + name, + serialNumber: defaults.serialNumber, + model: defaults.model, + firmwareVersion: defaults.firmwareVersion, + }, + }, + }; + }, + }; + } + + private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { + id?: string; + host?: string; + port?: number; + protocol?: TAirgradientProtocol; + name?: string; + serialNumber?: string; + model?: string; + firmwareVersion?: string; + measures?: IAirgradientMeasures; + deviceConfig?: IAirgradientDeviceConfig; + } { + const metadata = candidateArg.metadata || {}; + return { + id: candidateArg.id, + host: candidateArg.host, + port: candidateArg.port || airgradientDefaultPort, + protocol: this.protocolValue(metadata.protocol), + name: candidateArg.name, + serialNumber: candidateArg.serialNumber || candidateArg.id, + model: candidateArg.model, + firmwareVersion: this.stringValue(metadata.firmwareVersion), + measures: this.recordValue(metadata.measures) as IAirgradientMeasures | undefined, + deviceConfig: this.recordValue(metadata.deviceConfig) as IAirgradientDeviceConfig | undefined, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private protocolValue(valueArg: unknown): TAirgradientProtocol | undefined { + return valueArg === 'http' || valueArg === 'https' ? valueArg : undefined; + } + + private recordValue(valueArg: unknown): Record | undefined { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record : undefined; + } +} diff --git a/ts/integrations/airgradient/airgradient.classes.integration.ts b/ts/integrations/airgradient/airgradient.classes.integration.ts index 4bb14a2..c490e78 100644 --- a/ts/integrations/airgradient/airgradient.classes.integration.ts +++ b/ts/integrations/airgradient/airgradient.classes.integration.ts @@ -1,28 +1,79 @@ -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 { AirgradientClient } from './airgradient.classes.client.js'; +import { AirgradientConfigFlow } from './airgradient.classes.configflow.js'; +import { createAirgradientDiscoveryDescriptor } from './airgradient.discovery.js'; +import { AirgradientMapper } from './airgradient.mapper.js'; +import type { IAirgradientConfig } from './airgradient.types.js'; -export class HomeAssistantAirgradientIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "airgradient", - displayName: "AirGradient", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/airgradient", - "upstreamDomain": "airgradient", - "integrationType": "device", - "iotClass": "local_polling", - "qualityScale": "platinum", - "requirements": [ - "airgradient==0.9.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@airgradienthq", - "@joostlek" - ] -}, - }); +export class AirgradientIntegration extends BaseIntegration { + public readonly domain = 'airgradient'; + public readonly displayName = 'AirGradient'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAirgradientDiscoveryDescriptor(); + public readonly configFlow = new AirgradientConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/airgradient', + upstreamDomain: 'airgradient', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'platinum', + requirements: ['airgradient==0.9.2'], + dependencies: [] as string[], + afterDependencies: [] as string[], + codeowners: ['@airgradienthq', '@joostlek'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/airgradient', + zeroconf: ['_airgradient._tcp.local.'], + runtime: { + type: 'control-runtime', + polling: 'local HTTP snapshot', + endpoints: ['GET /measures/current', 'GET /config', 'PUT /config'], + services: ['set_config', 'request_co2_calibration', 'request_led_bar_test', 'refresh'], + }, + }; + + public async setup(configArg: IAirgradientConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AirgradientRuntime(new AirgradientClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAirgradientIntegration extends AirgradientIntegration {} + +class AirgradientRuntime implements IIntegrationRuntime { + public domain = 'airgradient'; + + constructor(private readonly client: AirgradientClient) {} + + public async devices(): Promise { + return AirgradientMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AirgradientMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AirgradientMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = AirgradientMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported AirGradient service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/airgradient/airgradient.discovery.ts b/ts/integrations/airgradient/airgradient.discovery.ts new file mode 100644 index 0000000..f4b52a2 --- /dev/null +++ b/ts/integrations/airgradient/airgradient.discovery.ts @@ -0,0 +1,183 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IAirgradientManualEntry, IAirgradientMdnsRecord } from './airgradient.types.js'; +import { airgradientDefaultPort, airgradientMdnsType, airgradientMinFirmwareVersion } from './airgradient.types.js'; + +const airgradientDomain = 'airgradient'; + +export class AirgradientMdnsMatcher implements IDiscoveryMatcher { + public id = 'airgradient-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize AirGradient zeroconf advertisements.'; + + public async matches(recordArg: IAirgradientMdnsRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const properties = normalizeKeys({ ...recordArg.txt, ...recordArg.properties }); + const type = normalizeType(recordArg.type || recordArg.serviceType); + const serialNumber = textValue(properties.serialno || properties.serial || properties.serial_number); + const model = textValue(properties.model); + const firmwareVersion = textValue(properties.fw_ver || properties.firmware || properties.version); + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const matched = type === airgradientMdnsType || Boolean(serialNumber && model) || includesAirgradient(recordArg.name) || includesAirgradient(model); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not an AirGradient advertisement.' }; + } + + const firmwareSupported = firmwareVersion ? versionAtLeast(firmwareVersion, airgradientMinFirmwareVersion) : undefined; + return { + matched: true, + confidence: serialNumber && host ? 'certain' : host ? 'high' : 'medium', + reason: 'mDNS record matches AirGradient zeroconf metadata.', + normalizedDeviceId: serialNumber || recordArg.name || host, + candidate: { + source: 'mdns', + integrationDomain: airgradientDomain, + id: serialNumber || recordArg.name || host, + host, + port: recordArg.port || airgradientDefaultPort, + name: cleanServiceName(recordArg.name) || model || 'AirGradient', + manufacturer: 'AirGradient', + model, + serialNumber, + metadata: { + discoveryProtocol: 'zeroconf', + mdnsName: recordArg.name, + mdnsType: type, + txt: properties, + firmwareVersion, + firmwareSupported, + }, + }, + metadata: { firmwareVersion, firmwareSupported }, + }; + } +} + +export class AirgradientManualMatcher implements IDiscoveryMatcher { + public id = 'airgradient-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual AirGradient local HTTP setup entries.'; + + public async matches(inputArg: IAirgradientManualEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.measures?.model].filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + const matched = Boolean(inputArg.host || inputArg.snapshot || inputArg.measures || inputArg.deviceConfig || inputArg.metadata?.airgradient || text.includes('airgradient')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain AirGradient setup hints.' }; + } + const id = inputArg.serialNumber || inputArg.id || inputArg.measures?.serialNumber || inputArg.host; + return { + matched: true, + confidence: inputArg.host && id ? 'high' : inputArg.host ? 'medium' : 'low', + reason: 'Manual entry can start AirGradient setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: airgradientDomain, + id, + host: inputArg.host, + port: inputArg.port || airgradientDefaultPort, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'AirGradient', + model: inputArg.model || inputArg.measures?.model, + serialNumber: inputArg.serialNumber || inputArg.measures?.serialNumber, + metadata: { + ...inputArg.metadata, + discoveryProtocol: 'manual', + protocol: inputArg.protocol, + firmwareVersion: inputArg.firmwareVersion || inputArg.measures?.firmwareVersion, + measures: inputArg.measures, + deviceConfig: inputArg.deviceConfig, + snapshot: inputArg.snapshot, + }, + }, + }; + } +} + +export class AirgradientCandidateValidator implements IDiscoveryValidator { + public id = 'airgradient-candidate-validator'; + public description = 'Validate AirGradient candidates from zeroconf and manual setup.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + const metadata = candidateArg.metadata || {}; + const protocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined; + const firmwareVersion = typeof metadata.firmwareVersion === 'string' ? metadata.firmwareVersion : undefined; + if (firmwareVersion && !versionAtLeast(firmwareVersion, airgradientMinFirmwareVersion)) { + return { + matched: false, + confidence: 'low', + reason: `AirGradient firmware ${firmwareVersion} is below the supported minimum ${airgradientMinFirmwareVersion}.`, + normalizedDeviceId: candidateArg.serialNumber || candidateArg.id || candidateArg.host, + metadata: { firmwareVersion, firmwareSupported: false }, + }; + } + + const text = [candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter(Boolean).join(' ').toLowerCase(); + const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : ''; + const matched = candidateArg.integrationDomain === airgradientDomain + || protocol === 'zeroconf' + || protocol === 'manual' + || mdnsType === airgradientMdnsType + || text.includes('airgradient') + || metadata.airgradient === true + || (candidateArg.source === 'manual' && Boolean(candidateArg.host)); + + return { + matched, + confidence: matched && candidateArg.serialNumber && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has AirGradient metadata or manual host information.' : 'Candidate is not AirGradient.', + candidate: matched ? { + ...candidateArg, + integrationDomain: airgradientDomain, + manufacturer: candidateArg.manufacturer || 'AirGradient', + port: candidateArg.port || airgradientDefaultPort, + } : undefined, + normalizedDeviceId: candidateArg.serialNumber || candidateArg.id || candidateArg.host, + metadata: matched ? { discoveryProtocol: protocol, firmwareVersion, firmwareSupported: firmwareVersion ? true : undefined } : undefined, + }; + } +} + +export const createAirgradientDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: airgradientDomain, displayName: 'AirGradient' }) + .addMatcher(new AirgradientMdnsMatcher()) + .addMatcher(new AirgradientManualMatcher()) + .addValidator(new AirgradientCandidateValidator()); +}; + +export const versionAtLeast = (versionArg: string, minimumArg: string): boolean => { + const left = versionParts(versionArg); + const right = versionParts(minimumArg); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const leftPart = left[index] || 0; + const rightPart = right[index] || 0; + if (leftPart > rightPart) { + return true; + } + if (leftPart < rightPart) { + return false; + } + } + return true; +}; + +const versionParts = (valueArg: string): number[] => valueArg.split(/[.-]/).map((partArg) => Number.parseInt(partArg.replace(/[^0-9]/g, '') || '0', 10)); + +const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, ''); + +const normalizeKeys = (recordArg: Record): Record => { + const normalized: Record = {}; + for (const [key, value] of Object.entries(recordArg)) { + normalized[key.toLowerCase()] = value; + } + return normalized; +}; + +const textValue = (valueArg?: string): string | undefined => valueArg?.trim() || undefined; + +const cleanServiceName = (valueArg?: string): string | undefined => valueArg?.replace(/\._airgradient\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined; + +const includesAirgradient = (valueArg?: string): boolean => Boolean(valueArg?.toLowerCase().includes('airgradient')); diff --git a/ts/integrations/airgradient/airgradient.mapper.ts b/ts/integrations/airgradient/airgradient.mapper.ts new file mode 100644 index 0000000..3978492 --- /dev/null +++ b/ts/integrations/airgradient/airgradient.mapper.ts @@ -0,0 +1,495 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IAirgradientCommand, + IAirgradientDevice, + IAirgradientDeviceConfig, + IAirgradientEvent, + IAirgradientMeasures, + IAirgradientSnapshot, + TAirgradientConfigField, + TAirgradientDisplayPmStandard, + TAirgradientPmStandard, +} from './airgradient.types.js'; +import { airgradientDefaultPort } from './airgradient.types.js'; + +const airgradientDomain = 'airgradient'; +const airgradientManufacturer = 'AirGradient'; +const learningOffsetOptions = ['12', '60', '120', '360', '720']; +const abcDaysOptions = ['1', '8', '30', '90', '180', '0']; + +interface IAirgradientSensorDescription { + key: string; + name: string; + value: (measuresArg: IAirgradientMeasures) => unknown; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + enabledDefault?: boolean; +} + +interface IAirgradientConfigSensorDescription { + key: string; + name: string; + value: (configArg: IAirgradientDeviceConfig) => unknown; + unit?: string; + deviceClass?: string; + entityCategory?: string; + options?: string[]; +} + +interface IAirgradientWritableDescription { + platform: TEntityPlatform; + key: string; + name: string; + field: TAirgradientConfigField; + value: (configArg: IAirgradientDeviceConfig) => unknown; + options?: string[]; + min?: number; + max?: number; + step?: number; + unit?: string; + entityCategory?: string; + localOnly?: boolean; +} + +const measurementSensors: IAirgradientSensorDescription[] = [ + { key: 'pm01', name: 'PM1', value: (measuresArg) => measuresArg.pm01, unit: 'ug/m3', deviceClass: 'pm1', stateClass: 'measurement' }, + { key: 'pm02', name: 'PM2.5', value: (measuresArg) => measuresArg.pm02, unit: 'ug/m3', deviceClass: 'pm25', stateClass: 'measurement' }, + { key: 'pm10', name: 'PM10', value: (measuresArg) => measuresArg.pm10, unit: 'ug/m3', deviceClass: 'pm10', stateClass: 'measurement' }, + { key: 'temperature', name: 'Temperature', value: (measuresArg) => measuresArg.ambientTemperature, unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { key: 'humidity', name: 'Humidity', value: (measuresArg) => measuresArg.relativeHumidity, unit: '%', deviceClass: 'humidity', stateClass: 'measurement' }, + { key: 'signal_strength', name: 'Signal strength', value: (measuresArg) => measuresArg.signalStrength, unit: 'dBm', deviceClass: 'signal_strength', stateClass: 'measurement', entityCategory: 'diagnostic', enabledDefault: false }, + { key: 'tvoc', name: 'VOC index', value: (measuresArg) => measuresArg.totalVolatileOrganicComponentIndex, stateClass: 'measurement' }, + { key: 'nitrogen_index', name: 'NOx index', value: (measuresArg) => measuresArg.nitrogenIndex, stateClass: 'measurement' }, + { key: 'co2', name: 'CO2', value: (measuresArg) => measuresArg.rco2, unit: 'ppm', deviceClass: 'co2', stateClass: 'measurement' }, + { key: 'pm003', name: 'PM0.3', value: (measuresArg) => measuresArg.pm003Count, unit: 'particles/dL', stateClass: 'measurement' }, + { key: 'nox_raw', name: 'Raw NOx', value: (measuresArg) => measuresArg.rawNitrogen, unit: 'ticks', stateClass: 'measurement', enabledDefault: false }, + { key: 'tvoc_raw', name: 'Raw VOC', value: (measuresArg) => measuresArg.rawTotalVolatileOrganicComponent, unit: 'ticks', stateClass: 'measurement', enabledDefault: false }, + { key: 'pm02_raw', name: 'Raw PM2.5', value: (measuresArg) => measuresArg.rawPm02, unit: 'ug/m3', deviceClass: 'pm25', stateClass: 'measurement', enabledDefault: false }, +]; + +const configSensors: IAirgradientConfigSensorDescription[] = [ + { key: 'co2_automatic_baseline_calibration_days', name: 'Carbon dioxide automatic baseline calibration', value: (configArg) => configArg.co2AutomaticBaselineCalibrationDays, unit: 'd', deviceClass: 'duration', entityCategory: 'diagnostic' }, + { key: 'nox_learning_offset', name: 'NOx index learning offset', value: (configArg) => configArg.noxLearningOffset, unit: 'h', deviceClass: 'duration', entityCategory: 'diagnostic' }, + { key: 'tvoc_learning_offset', name: 'VOC index learning offset', value: (configArg) => configArg.tvocLearningOffset, unit: 'h', deviceClass: 'duration', entityCategory: 'diagnostic' }, +]; + +const ledBarConfigSensors: IAirgradientConfigSensorDescription[] = [ + { key: 'led_bar_mode', name: 'LED bar mode', value: (configArg) => configArg.ledBarMode, deviceClass: 'enum', options: ['off', 'co2', 'pm'], entityCategory: 'diagnostic' }, + { key: 'led_bar_brightness', name: 'LED bar brightness', value: (configArg) => configArg.ledBarBrightness, unit: '%', entityCategory: 'diagnostic' }, +]; + +const displayConfigSensors: IAirgradientConfigSensorDescription[] = [ + { key: 'display_temperature_unit', name: 'Display temperature unit', value: (configArg) => configArg.temperatureUnit, deviceClass: 'enum', options: ['c', 'f'], entityCategory: 'diagnostic' }, + { key: 'display_pm_standard', name: 'Display PM standard', value: (configArg) => displayPmStandard(configArg.pmStandard), deviceClass: 'enum', options: ['ugm3', 'us_aqi'], entityCategory: 'diagnostic' }, + { key: 'display_brightness', name: 'Display brightness', value: (configArg) => configArg.displayBrightness, unit: '%', entityCategory: 'diagnostic' }, +]; + +const writableDescriptions: IAirgradientWritableDescription[] = [ + { platform: 'select', key: 'configuration_control', name: 'Configuration source', field: 'configurationControl', value: (configArg) => configArg.configurationControl === 'both' ? null : configArg.configurationControl, options: ['cloud', 'local'], entityCategory: 'config' }, + { platform: 'select', key: 'nox_index_learning_time_offset', name: 'NOx index learning offset', field: 'noxLearningOffset', value: (configArg) => optionValue(configArg.noxLearningOffset, learningOffsetOptions), options: learningOffsetOptions, entityCategory: 'config', localOnly: true }, + { platform: 'select', key: 'voc_index_learning_time_offset', name: 'VOC index learning offset', field: 'tvocLearningOffset', value: (configArg) => optionValue(configArg.tvocLearningOffset, learningOffsetOptions), options: learningOffsetOptions, entityCategory: 'config', localOnly: true }, + { platform: 'select', key: 'co2_automatic_baseline_calibration', name: 'CO2 automatic baseline duration', field: 'abcDays', value: (configArg) => optionValue(configArg.co2AutomaticBaselineCalibrationDays, abcDaysOptions), options: abcDaysOptions, entityCategory: 'config', localOnly: true }, + { platform: 'select', key: 'display_temperature_unit', name: 'Display temperature unit', field: 'temperatureUnit', value: (configArg) => configArg.temperatureUnit, options: ['c', 'f'], entityCategory: 'config', localOnly: true }, + { platform: 'select', key: 'display_pm_standard', name: 'Display PM standard', field: 'pmStandard', value: (configArg) => displayPmStandard(configArg.pmStandard), options: ['ugm3', 'us_aqi'], entityCategory: 'config', localOnly: true }, + { platform: 'select', key: 'led_bar_mode', name: 'LED bar mode', field: 'ledBarMode', value: (configArg) => configArg.ledBarMode, options: ['off', 'co2', 'pm'], entityCategory: 'config', localOnly: true }, + { platform: 'number', key: 'display_brightness', name: 'Display brightness', field: 'displayBrightness', value: (configArg) => configArg.displayBrightness, min: 0, max: 100, step: 1, unit: '%', entityCategory: 'config', localOnly: true }, + { platform: 'number', key: 'led_bar_brightness', name: 'LED bar brightness', field: 'ledBarBrightness', value: (configArg) => configArg.ledBarBrightness, min: 0, max: 100, step: 1, unit: '%', entityCategory: 'config', localOnly: true }, + { platform: 'switch', key: 'post_data_to_airgradient', name: 'Post data to AirGradient', field: 'postDataToAirGradient', value: (configArg) => configArg.postDataToAirGradient ? 'on' : 'off', entityCategory: 'config', localOnly: true }, + { platform: 'button', key: 'co2_calibration', name: 'Calibrate CO2 sensor', field: 'co2CalibrationRequested', value: () => 'idle', entityCategory: 'config', localOnly: true }, + { platform: 'button', key: 'led_bar_test', name: 'Test LED bar', field: 'ledBarTestRequested', value: () => 'idle', entityCategory: 'config', localOnly: true }, +]; + +export class AirgradientMapper { + public static toDevices(snapshotArg: IAirgradientSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return snapshotArg.devices.map((deviceArg) => this.toDevice(deviceArg, snapshotArg.connected, updatedAt)); + } + + public static toEntities(snapshotArg: IAirgradientSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + + for (const device of snapshotArg.devices) { + const deviceId = this.deviceId(device); + const name = this.deviceName(device); + const available = snapshotArg.connected && device.online !== false; + const measures = device.measures; + const config = device.config; + + if (measures) { + for (const sensor of measurementSensors) { + const value = sensor.value(measures); + if (value === undefined || value === null) { + continue; + } + entities.push(this.entity('sensor', `${name} ${sensor.name}`, deviceId, this.uniqueId(device, sensor.key), value, usedIds, { + deviceClass: sensor.deviceClass, + stateClass: sensor.stateClass, + unit: sensor.unit, + entityCategory: sensor.entityCategory, + enabledDefault: sensor.enabledDefault, + }, available)); + } + } + + if (config) { + for (const sensor of this.configSensorDescriptions(device)) { + entities.push(this.entity('sensor', `${name} ${sensor.name}`, deviceId, this.uniqueId(device, sensor.key), sensor.value(config) ?? 'unknown', usedIds, { + deviceClass: sensor.deviceClass, + unit: sensor.unit, + entityCategory: sensor.entityCategory, + options: sensor.options, + enabledDefault: config.configurationControl !== 'local' ? false : undefined, + }, available)); + } + + for (const description of this.writableDescriptionsForDevice(device)) { + if (description.localOnly && config.configurationControl !== 'local') { + continue; + } + entities.push(this.entity(description.platform, `${name} ${description.name}`, deviceId, this.uniqueId(device, description.key), description.value(config) ?? 'unknown', usedIds, { + airgradientConfigField: description.field, + options: description.options, + min: description.min, + max: description.max, + step: description.step, + unit: description.unit, + entityCategory: description.entityCategory, + writable: true, + }, available)); + } + } + + if (device.firmwareVersion || device.latestFirmwareVersion) { + entities.push(this.entity('update', `${name} Firmware`, deviceId, this.uniqueId(device, 'update'), device.firmwareVersion || 'unknown', usedIds, { + deviceClass: 'firmware', + installedVersion: device.firmwareVersion, + latestVersion: device.latestFirmwareVersion, + }, available)); + } + } + + return entities; + } + + public static commandForService(snapshotArg: IAirgradientSnapshot, requestArg: IServiceCallRequest): IAirgradientCommand | undefined { + if (requestArg.domain === airgradientDomain && ['refresh', 'reload'].includes(requestArg.service)) { + return { type: 'refresh', service: requestArg.service }; + } + + if (requestArg.domain === airgradientDomain && requestArg.service === 'set_config') { + const field = this.stringData(requestArg, 'field'); + const value = requestArg.data?.value; + return this.commandForField(field, value, requestArg); + } + + if (requestArg.domain === airgradientDomain && requestArg.service === 'request_co2_calibration') { + return this.commandForField('co2CalibrationRequested', true, requestArg); + } + + if (requestArg.domain === airgradientDomain && requestArg.service === 'request_led_bar_test') { + return this.commandForField('ledBarTestRequested', true, requestArg); + } + + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target || target.attributes?.writable !== true || typeof target.attributes.airgradientConfigField !== 'string') { + return undefined; + } + const field = target.attributes.airgradientConfigField; + const value = this.valueForEntityCommand(target, requestArg); + return this.commandForField(field, value, requestArg, target); + } + + public static toIntegrationEvent(eventArg: IAirgradientEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: airgradientDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + private static toDevice(deviceArg: IAirgradientDevice, connectedArg: boolean, 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: connectedArg && deviceArg.online !== false ? 'online' : 'offline', updatedAt: updatedAtArg }, + ]; + + if (deviceArg.measures) { + for (const sensor of measurementSensors) { + const value = sensor.value(deviceArg.measures); + if (value === undefined || value === null) { + continue; + } + features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit }); + this.pushDeviceState(state, sensor.key, value, updatedAtArg); + } + } + + if (deviceArg.config) { + for (const description of this.writableDescriptionsForDevice(deviceArg)) { + const writable = description.localOnly ? deviceArg.config.configurationControl === 'local' : true; + features.push({ id: description.key, capability: description.platform === 'number' || description.platform === 'select' ? 'sensor' : 'switch', name: description.name, readable: true, writable, unit: description.unit }); + this.pushDeviceState(state, description.key, description.value(deviceArg.config), updatedAtArg); + } + } + + return { + id: this.deviceId(deviceArg), + integrationDomain: airgradientDomain, + name: this.deviceName(deviceArg), + protocol: 'http', + manufacturer: deviceArg.manufacturer || airgradientManufacturer, + model: deviceArg.modelName || deviceArg.model || 'AirGradient monitor', + online: connectedArg && deviceArg.online !== false, + features, + state, + metadata: this.cleanAttributes({ + host: deviceArg.host, + port: deviceArg.port || airgradientDefaultPort, + serialNumber: deviceArg.serialNumber, + firmwareVersion: deviceArg.firmwareVersion, + latestFirmwareVersion: deviceArg.latestFirmwareVersion, + modelId: deviceArg.model, + ...deviceArg.metadata, + }), + }; + } + + private static configSensorDescriptions(deviceArg: IAirgradientDevice): IAirgradientConfigSensorDescription[] { + const descriptions = [...configSensors]; + if (this.hasLedBar(deviceArg)) { + descriptions.push(...ledBarConfigSensors); + } + if (this.hasDisplay(deviceArg)) { + descriptions.push(...displayConfigSensors); + } + return descriptions; + } + + private static writableDescriptionsForDevice(deviceArg: IAirgradientDevice): IAirgradientWritableDescription[] { + return writableDescriptions.filter((descriptionArg) => { + if (descriptionArg.key.startsWith('display_')) { + return this.hasDisplay(deviceArg); + } + if (descriptionArg.key.startsWith('led_bar') || descriptionArg.key === 'led_bar_test') { + return this.hasLedBar(deviceArg); + } + return true; + }); + } + + private static valueForEntityCommand(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): unknown { + const field = entityArg.attributes?.airgradientConfigField; + if (entityArg.platform === 'switch') { + if (requestArg.service === 'turn_on') { + return true; + } + if (requestArg.service === 'turn_off') { + return false; + } + } + if (entityArg.platform === 'button' && requestArg.service === 'press') { + return true; + } + if (entityArg.platform === 'number' && requestArg.service === 'set_value') { + return this.numberData(requestArg, 'value'); + } + if (entityArg.platform === 'select' && requestArg.service === 'select_option') { + const option = this.stringData(requestArg, 'option'); + if (field === 'pmStandard') { + return pmStandardFromDisplay(option); + } + if (field === 'noxLearningOffset' || field === 'tvocLearningOffset' || field === 'abcDays') { + return option && /^\d+$/.test(option) ? Number(option) : undefined; + } + return option; + } + return undefined; + } + + private static commandForField(fieldArg: unknown, valueArg: unknown, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IAirgradientCommand | undefined { + if (!this.isConfigField(fieldArg)) { + return undefined; + } + const normalizedValue = this.normalizeCommandValue(fieldArg, valueArg); + if (normalizedValue === undefined) { + return undefined; + } + return { + type: 'set_config', + service: requestArg.service, + field: fieldArg, + value: normalizedValue, + payload: { [fieldArg]: normalizedValue }, + deviceId: entityArg?.deviceId || requestArg.target.deviceId, + entityId: entityArg?.id || requestArg.target.entityId, + target: requestArg.target, + }; + } + + private static normalizeCommandValue(fieldArg: TAirgradientConfigField, valueArg: unknown): unknown { + if (fieldArg === 'configurationControl') { + return valueArg === 'cloud' || valueArg === 'local' ? valueArg : undefined; + } + if (fieldArg === 'pmStandard') { + return valueArg === 'ugm3' || valueArg === 'us-aqi' ? valueArg : undefined; + } + if (fieldArg === 'temperatureUnit') { + return valueArg === 'c' || valueArg === 'f' ? valueArg : undefined; + } + if (fieldArg === 'ledBarMode') { + return valueArg === 'off' || valueArg === 'co2' || valueArg === 'pm' ? valueArg : undefined; + } + if (fieldArg === 'postDataToAirGradient' || fieldArg === 'co2CalibrationRequested' || fieldArg === 'ledBarTestRequested') { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + if (fieldArg === 'displayBrightness' || fieldArg === 'ledBarBrightness') { + return this.numberInRange(valueArg, 0, 100); + } + if (fieldArg === 'noxLearningOffset' || fieldArg === 'tvocLearningOffset') { + const value = this.integerValue(valueArg); + return value !== undefined && learningOffsetOptions.includes(String(value)) ? value : undefined; + } + if (fieldArg === 'abcDays') { + const value = this.integerValue(valueArg); + return value !== undefined && abcDaysOptions.includes(String(value)) ? value : undefined; + } + return undefined; + } + + private static findTargetEntity(snapshotArg: IAirgradientSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && Boolean(entityArg.attributes?.writable)); + } + return entities.find((entityArg) => entityArg.platform === requestArg.domain && Boolean(entityArg.attributes?.writable)); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record, availableArg: boolean): IIntegrationEntity { + const baseId = `${platformArg}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return { + id: seen ? `${baseId}_${seen + 1}` : baseId, + uniqueId: uniqueIdArg, + integrationDomain: airgradientDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static deviceId(deviceArg: IAirgradientDevice): string { + return `airgradient.device.${this.slug(deviceArg.serialNumber || deviceArg.id || deviceArg.host || this.deviceName(deviceArg))}`; + } + + private static uniqueId(deviceArg: IAirgradientDevice, keyArg: string): string { + return `airgradient_${this.slug(deviceArg.serialNumber || deviceArg.id || deviceArg.host || this.deviceName(deviceArg))}_${this.slug(keyArg)}`; + } + + private static deviceName(deviceArg: IAirgradientDevice): string { + return deviceArg.name || deviceArg.modelName || (deviceArg.model ? `${airgradientManufacturer} ${deviceArg.model}` : `${airgradientManufacturer} monitor`); + } + + private static hasDisplay(deviceArg: IAirgradientDevice): boolean { + return Boolean(deviceArg.model?.includes('I') || deviceArg.measures?.model?.includes('I')); + } + + private static hasLedBar(deviceArg: IAirgradientDevice): boolean { + return Boolean(deviceArg.model?.includes('L') || deviceArg.measures?.model?.includes('L')); + } + + private static pushDeviceState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void { + if (valueArg === undefined) { + return; + } + stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + return this.numberValue(requestArg.data?.[keyArg]); + } + + private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static numberInRange(valueArg: unknown, minArg: number, maxArg: number): number | undefined { + const value = this.integerValue(valueArg); + return value !== undefined && value >= minArg && value <= maxArg ? value : undefined; + } + + private static integerValue(valueArg: unknown): number | undefined { + const value = this.numberValue(valueArg); + return value === undefined ? undefined : Math.trunc(value); + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private static isConfigField(valueArg: unknown): valueArg is TAirgradientConfigField { + return typeof valueArg === 'string' && [ + 'pmStandard', + 'temperatureUnit', + 'configurationControl', + 'ledBarMode', + 'co2CalibrationRequested', + 'ledBarTestRequested', + 'displayBrightness', + 'ledBarBrightness', + 'postDataToAirGradient', + 'abcDays', + 'noxLearningOffset', + 'tvocLearningOffset', + ].includes(valueArg); + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'airgradient'; + } +} + +const displayPmStandard = (valueArg: TAirgradientPmStandard | undefined): TAirgradientDisplayPmStandard | undefined => valueArg === 'us-aqi' ? 'us_aqi' : valueArg; +const pmStandardFromDisplay = (valueArg: string | undefined): TAirgradientPmStandard | undefined => valueArg === 'us_aqi' ? 'us-aqi' : valueArg === 'ugm3' ? 'ugm3' : undefined; +const optionValue = (valueArg: unknown, optionsArg: string[]): string | undefined => { + const text = typeof valueArg === 'number' ? String(valueArg) : typeof valueArg === 'string' ? valueArg : undefined; + return text && optionsArg.includes(text) ? text : undefined; +}; diff --git a/ts/integrations/airgradient/airgradient.types.ts b/ts/integrations/airgradient/airgradient.types.ts index 5752308..ae36fa1 100644 --- a/ts/integrations/airgradient/airgradient.types.ts +++ b/ts/integrations/airgradient/airgradient.types.ts @@ -1,4 +1,214 @@ -export interface IHomeAssistantAirgradientConfig { - // TODO: replace with the TypeScript-native config for airgradient. +import type { IServiceCallRequest } from '../../core/types.js'; + +export const airgradientDefaultPort = 80; +export const airgradientMdnsType = '_airgradient._tcp.local'; +export const airgradientMinFirmwareVersion = '3.1.1'; + +export type TAirgradientProtocol = 'http' | 'https'; +export type TAirgradientConfigurationControl = 'cloud' | 'local' | 'both'; +export type TAirgradientPmStandard = 'ugm3' | 'us-aqi'; +export type TAirgradientDisplayPmStandard = 'ugm3' | 'us_aqi'; +export type TAirgradientTemperatureUnit = 'c' | 'f'; +export type TAirgradientLedBarMode = 'off' | 'co2' | 'pm'; + +export type TAirgradientConfigField = + | 'pmStandard' + | 'temperatureUnit' + | 'configurationControl' + | 'ledBarMode' + | 'co2CalibrationRequested' + | 'ledBarTestRequested' + | 'displayBrightness' + | 'ledBarBrightness' + | 'postDataToAirGradient' + | 'abcDays' + | 'noxLearningOffset' + | 'tvocLearningOffset'; + +export interface IAirgradientConfig { + host?: string; + port?: number; + protocol?: TAirgradientProtocol; + timeoutMs?: number; + name?: string; + uniqueId?: string; + serialNumber?: string; + model?: string; + firmwareVersion?: string; + latestFirmwareVersion?: string; + connected?: boolean; + measures?: IAirgradientMeasures; + deviceConfig?: IAirgradientDeviceConfig; + deviceInfo?: IAirgradientDeviceInfo; + snapshot?: IAirgradientSnapshot; + devices?: IAirgradientDevice[]; + manualEntries?: IAirgradientManualEntry[]; + events?: IAirgradientEvent[]; + checkFirmwareUpdate?: boolean; + commandExecutor?: TAirgradientCommandExecutor; + [key: string]: unknown; +} + +export interface IHomeAssistantAirgradientConfig extends IAirgradientConfig {} + +export interface IAirgradientDeviceInfo { + id?: string; + host?: string; + port?: number; + protocol?: TAirgradientProtocol; + name?: string; + manufacturer?: string; + model?: string; + modelName?: string; + serialNumber?: string; + firmwareVersion?: string; + latestFirmwareVersion?: string; + [key: string]: unknown; +} + +export interface IAirgradientMeasures { + signalStrength?: number; + serialNumber?: string; + bootTime?: number; + firmwareVersion?: string; + model?: string; + rco2?: number | null; + pm01?: number | null; + pm02?: number | null; + rawPm02?: number | null; + compensatedPm02?: number | null; + pm10?: number | null; + totalVolatileOrganicComponentIndex?: number | null; + rawTotalVolatileOrganicComponent?: number | null; + pm003Count?: number | null; + nitrogenIndex?: number | null; + rawNitrogen?: number | null; + ambientTemperature?: number | null; + rawAmbientTemperature?: number | null; + compensatedAmbientTemperature?: number | null; + relativeHumidity?: number | null; + rawRelativeHumidity?: number | null; + compensatedRelativeHumidity?: number | null; + raw?: Record; + [key: string]: unknown; +} + +export interface IAirgradientDeviceConfig { + country?: string; + pmStandard?: TAirgradientPmStandard; + ledBarMode?: TAirgradientLedBarMode; + co2AutomaticBaselineCalibrationDays?: number; + temperatureUnit?: TAirgradientTemperatureUnit; + configurationControl?: TAirgradientConfigurationControl; + postDataToAirGradient?: boolean; + ledBarBrightness?: number; + displayBrightness?: number; + noxLearningOffset?: number; + tvocLearningOffset?: number; + raw?: Record; + [key: string]: unknown; +} + +export interface IAirgradientDevice { + id?: string; + host?: string; + port?: number; + protocol?: TAirgradientProtocol; + name?: string; + manufacturer?: string; + model?: string; + modelName?: string; + serialNumber?: string; + firmwareVersion?: string; + latestFirmwareVersion?: string; + online?: boolean; + measures?: IAirgradientMeasures; + config?: IAirgradientDeviceConfig; + attributes?: Record; + metadata?: Record; + [key: string]: unknown; +} + +export interface IAirgradientSnapshot { + connected: boolean; + updatedAt: string; + devices: IAirgradientDevice[]; + events: IAirgradientEvent[]; + raw?: Record; +} + +export type TAirgradientEventType = + | 'snapshot_refreshed' + | 'command_mapped' + | 'command_executed' + | 'command_failed' + | 'error'; + +export interface IAirgradientEvent { + type: TAirgradientEventType | string; + timestamp: number; + deviceId?: string; + entityId?: string; + command?: IAirgradientCommand; + data?: unknown; + [key: string]: unknown; +} + +export type IAirgradientCommand = IAirgradientSetConfigCommand | IAirgradientRefreshCommand; + +export interface IAirgradientSetConfigCommand { + type: 'set_config'; + service: string; + field: TAirgradientConfigField; + value: unknown; + payload: Partial>; + deviceId?: string; + entityId?: string; + target?: IServiceCallRequest['target']; +} + +export interface IAirgradientRefreshCommand { + type: 'refresh'; + service?: string; +} + +export interface IAirgradientCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export type TAirgradientCommandExecutor = ( + commandArg: IAirgradientCommand +) => Promise | IAirgradientCommandResult | unknown; + +export interface IAirgradientManualEntry { + host?: string; + port?: number; + protocol?: TAirgradientProtocol; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + latestFirmwareVersion?: string; + measures?: IAirgradientMeasures; + deviceConfig?: IAirgradientDeviceConfig; + snapshot?: IAirgradientSnapshot; + metadata?: Record; + [key: string]: unknown; +} + +export interface IAirgradientMdnsRecord { + type?: string; + serviceType?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; [key: string]: unknown; } diff --git a/ts/integrations/airgradient/index.ts b/ts/integrations/airgradient/index.ts index 09c8535..7e653e2 100644 --- a/ts/integrations/airgradient/index.ts +++ b/ts/integrations/airgradient/index.ts @@ -1,2 +1,6 @@ +export * from './airgradient.classes.client.js'; +export * from './airgradient.classes.configflow.js'; export * from './airgradient.classes.integration.js'; +export * from './airgradient.discovery.js'; +export * from './airgradient.mapper.js'; export * from './airgradient.types.js'; diff --git a/ts/integrations/android_ip_webcam/.generated-by-smarthome-exchange b/ts/integrations/android_ip_webcam/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/android_ip_webcam/.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/android_ip_webcam/android_ip_webcam.classes.client.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.client.ts new file mode 100644 index 0000000..386143e --- /dev/null +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.client.ts @@ -0,0 +1,529 @@ +import type { + IAndroidIpWebcamBinarySensor, + IAndroidIpWebcamCamera, + IAndroidIpWebcamClientCommand, + IAndroidIpWebcamConfig, + IAndroidIpWebcamDeviceInfo, + IAndroidIpWebcamSensor, + IAndroidIpWebcamSensorData, + IAndroidIpWebcamSnapshot, + IAndroidIpWebcamSnapshotImage, + IAndroidIpWebcamStatusData, + IAndroidIpWebcamSwitch, + TAndroidIpWebcamOrientation, + TAndroidIpWebcamProtocol, + TAndroidIpWebcamRtspAudioCodec, + TAndroidIpWebcamRtspVideoCodec, +} from './android_ip_webcam.types.js'; +import { + androidIpWebcamDefaultPort, + androidIpWebcamDefaultTimeoutMs, + androidIpWebcamSensorDescriptions, + androidIpWebcamSwitchDescriptions, +} from './android_ip_webcam.types.js'; + +const allowedOrientations = new Set(['landscape', 'upsidedown', 'portrait', 'upsidedown_portrait']); + +export class AndroidIpWebcamHttpError extends Error { + constructor(public readonly status: number, messageArg: string) { + super(messageArg); + this.name = 'AndroidIpWebcamHttpError'; + } +} + +export class AndroidIpWebcamClient { + private snapshot?: IAndroidIpWebcamSnapshot; + + constructor(private readonly config: IAndroidIpWebcamConfig) {} + + public async getSnapshot(forceRefreshArg = false): Promise { + if (!forceRefreshArg && this.snapshot) { + return this.snapshot; + } + + if (!forceRefreshArg && this.config.snapshot) { + this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot)); + return this.snapshot; + } + + if (this.hasLiveTarget()) { + try { + this.snapshot = await this.fetchSnapshot(); + return this.snapshot; + } catch (errorArg) { + this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)); + return this.snapshot; + } + } + + this.snapshot = this.snapshotFromConfig(this.config.connected ?? false); + return this.snapshot; + } + + public async validateConnection(): Promise { + await this.fetchSnapshot(); + } + + public async execute(commandArg: IAndroidIpWebcamClientCommand): Promise { + if (commandArg.type === 'refresh') { + return this.getSnapshot(true); + } + if (commandArg.type === 'stream_source') { + return { + streamSource: this.rtspUrl(commandArg.videoCodec || 'h264', commandArg.audioCodec || 'aac'), + mjpegUrl: this.mjpegUrl(), + stillImageUrl: this.imageUrl(), + verified: false, + }; + } + if (commandArg.type === 'snapshot_image') { + if (commandArg.filename) { + throw new Error('Android IP Webcam snapshot file writes are not implemented; request data as base64 without data.filename.'); + } + const image = await this.getSnapshotImage(); + return { + contentType: image.contentType, + dataBase64: Buffer.from(image.data).toString('base64'), + }; + } + if (commandArg.type === 'setting') { + if (!commandArg.key) { + throw new Error('Android IP Webcam setting command requires a key.'); + } + await this.changeSetting(commandArg.key, commandArg.value); + return { ok: true, key: commandArg.key, value: commandArg.value }; + } + if (commandArg.type === 'torch') { + await this.getOk(commandArg.activate === false ? '/disabletorch' : '/enabletorch', 'torch'); + this.patchCachedSetting('torch', commandArg.activate !== false); + return { ok: true, key: 'torch', value: commandArg.activate !== false }; + } + if (commandArg.type === 'focus') { + await this.getOk(commandArg.activate === false ? '/nofocus' : '/focus', 'focus'); + this.patchCachedSetting('focus', commandArg.activate !== false); + return { ok: true, key: 'focus', value: commandArg.activate !== false }; + } + if (commandArg.type === 'record') { + await this.record(commandArg.record !== false, commandArg.tag); + return { ok: true, key: 'video_recording', value: commandArg.record !== false }; + } + if (commandArg.type === 'set_zoom') { + if (typeof commandArg.zoom !== 'number' || !Number.isFinite(commandArg.zoom)) { + throw new Error('Android IP Webcam set_zoom requires a numeric zoom value.'); + } + await this.getOk(`/settings/ptz?zoom=${encodeURIComponent(String(Math.round(commandArg.zoom)))}`, 'set_zoom'); + return { ok: true, key: 'zoom', value: Math.round(commandArg.zoom) }; + } + if (commandArg.type === 'set_quality') { + if (typeof commandArg.quality !== 'number' || !Number.isFinite(commandArg.quality)) { + throw new Error('Android IP Webcam set_quality requires a numeric quality value.'); + } + const quality = Math.max(0, Math.min(100, Math.round(commandArg.quality))); + await this.changeSetting('quality', quality); + return { ok: true, key: 'quality', value: quality }; + } + if (commandArg.type === 'set_orientation') { + if (!commandArg.orientation || !allowedOrientations.has(commandArg.orientation)) { + throw new Error('Android IP Webcam set_orientation requires a supported orientation.'); + } + await this.changeSetting('orientation', commandArg.orientation); + return { ok: true, key: 'orientation', value: commandArg.orientation }; + } + if (commandArg.type === 'set_scenemode') { + if (!commandArg.scenemode) { + throw new Error('Android IP Webcam set_scenemode requires a scenemode value.'); + } + await this.changeSetting('scenemode', commandArg.scenemode); + return { ok: true, key: 'scenemode', value: commandArg.scenemode }; + } + throw new Error(`Unsupported Android IP Webcam command: ${commandArg.type}`); + } + + public async getSnapshotImage(): Promise { + const response = await this.request('/shot.jpg'); + return { + contentType: response.headers.get('content-type') || 'image/jpeg', + data: new Uint8Array(await response.arrayBuffer()), + }; + } + + public async destroy(): Promise {} + + private async fetchSnapshot(): Promise { + const [statusData, sensorData] = await Promise.all([ + this.requestJson('/status.json?show_avail=1'), + this.requestJson('/sensors.json'), + ]); + return this.snapshotFromData(statusData, sensorData, true); + } + + private snapshotFromData(statusDataArg: IAndroidIpWebcamStatusData, sensorDataArg: IAndroidIpWebcamSensorData, connectedArg: boolean): IAndroidIpWebcamSnapshot { + const currentSettings = this.currentSettings(statusDataArg.curvals || this.config.currentSettings || {}); + const enabledSensors = this.config.enabledSensors || Object.keys(sensorDataArg); + const enabledSettings = this.config.enabledSettings || Object.keys(currentSettings); + const availableSettings = this.availableSettings(statusDataArg.avail || this.config.availableSettings || {}); + const deviceInfo = this.deviceInfo(connectedArg); + const snapshot: IAndroidIpWebcamSnapshot = { + deviceInfo, + camera: this.camera(deviceInfo, connectedArg), + sensors: this.config.sensors || this.sensors(statusDataArg, sensorDataArg, enabledSensors, connectedArg), + binarySensors: this.config.binarySensors || this.binarySensors(sensorDataArg, enabledSensors, connectedArg), + switches: this.config.switches || this.switches(currentSettings, enabledSettings, connectedArg), + statusData: statusDataArg, + sensorData: sensorDataArg, + currentSettings, + enabledSensors, + enabledSettings, + availableSettings, + connected: connectedArg, + updatedAt: new Date().toISOString(), + }; + return this.normalizeSnapshot(snapshot); + } + + private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IAndroidIpWebcamSnapshot { + const statusData = this.config.statusData || this.config.snapshot?.statusData || {}; + const sensorData = this.config.sensorData || this.config.snapshot?.sensorData || {}; + const currentSettings = this.currentSettings(this.config.currentSettings || this.config.snapshot?.currentSettings || statusData.curvals || {}); + const enabledSensors = this.config.enabledSensors || this.config.snapshot?.enabledSensors || Object.keys(sensorData); + const enabledSettings = this.config.enabledSettings || this.config.snapshot?.enabledSettings || Object.keys(currentSettings); + const availableSettings = this.availableSettings(this.config.availableSettings || this.config.snapshot?.availableSettings || statusData.avail || {}); + const deviceInfo = this.deviceInfo(connectedArg); + return this.normalizeSnapshot({ + deviceInfo, + camera: this.config.camera || this.config.snapshot?.camera || this.camera(deviceInfo, connectedArg), + sensors: this.config.sensors || this.config.snapshot?.sensors || this.sensors(statusData, sensorData, enabledSensors, connectedArg), + binarySensors: this.config.binarySensors || this.config.snapshot?.binarySensors || this.binarySensors(sensorData, enabledSensors, connectedArg), + switches: this.config.switches || this.config.snapshot?.switches || this.switches(currentSettings, enabledSettings, connectedArg), + statusData, + sensorData, + currentSettings, + enabledSensors, + enabledSettings, + availableSettings, + connected: connectedArg, + updatedAt: new Date().toISOString(), + metadata: { + ...this.config.snapshot?.metadata, + lastLiveError: lastErrorArg, + }, + }); + } + + private normalizeSnapshot(snapshotArg: IAndroidIpWebcamSnapshot): IAndroidIpWebcamSnapshot { + const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false); + const deviceInfo = { + ...this.deviceInfo(connected), + ...snapshotArg.deviceInfo, + online: connected, + }; + return { + ...snapshotArg, + deviceInfo, + camera: { + ...this.camera(deviceInfo, connected), + ...snapshotArg.camera, + available: connected && snapshotArg.camera.available !== false, + }, + sensors: snapshotArg.sensors || [], + binarySensors: snapshotArg.binarySensors || [], + switches: snapshotArg.switches || [], + statusData: snapshotArg.statusData || {}, + sensorData: snapshotArg.sensorData || {}, + currentSettings: snapshotArg.currentSettings || {}, + enabledSensors: snapshotArg.enabledSensors || [], + enabledSettings: snapshotArg.enabledSettings || [], + availableSettings: snapshotArg.availableSettings || {}, + connected, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + }; + } + + private sensors(statusDataArg: IAndroidIpWebcamStatusData, sensorDataArg: IAndroidIpWebcamSensorData, enabledSensorsArg: string[], connectedArg: boolean): IAndroidIpWebcamSensor[] { + const enabled = new Set(enabledSensorsArg); + const sensors: IAndroidIpWebcamSensor[] = []; + for (const description of androidIpWebcamSensorDescriptions) { + const isConnectionSensor = Boolean(description.statusKey); + if (!isConnectionSensor && !enabled.has(description.key)) { + continue; + } + const sensorDatum = sensorDataArg[description.key]; + const value = description.statusKey ? statusDataArg[description.statusKey] : this.sensorValue(sensorDatum); + sensors.push({ + key: description.key, + name: description.name, + value, + unit: sensorDatum?.unit, + deviceClass: description.deviceClass, + stateClass: description.stateClass, + entityCategory: description.entityCategory, + available: connectedArg && (isConnectionSensor || enabled.has(description.key)), + }); + } + return sensors; + } + + private binarySensors(sensorDataArg: IAndroidIpWebcamSensorData, enabledSensorsArg: string[], connectedArg: boolean): IAndroidIpWebcamBinarySensor[] { + const enabled = enabledSensorsArg.includes('motion_active'); + return [{ + key: 'motion_active', + name: 'Motion active', + isOn: this.sensorValue(sensorDataArg.motion_active) === 1 || this.sensorValue(sensorDataArg.motion_active) === 1.0, + deviceClass: 'motion', + available: connectedArg && enabled, + }]; + } + + private switches(currentSettingsArg: Record, enabledSettingsArg: string[], connectedArg: boolean): IAndroidIpWebcamSwitch[] { + const enabled = new Set(enabledSettingsArg); + return androidIpWebcamSwitchDescriptions + .filter((descriptionArg) => enabled.has(descriptionArg.key)) + .map((descriptionArg): IAndroidIpWebcamSwitch => ({ + key: descriptionArg.key, + name: descriptionArg.name, + isOn: Boolean(currentSettingsArg[descriptionArg.key]), + command: descriptionArg.command, + entityCategory: descriptionArg.entityCategory, + available: connectedArg, + })); + } + + private sensorValue(sensorDatumArg: unknown): unknown { + const data = record(sensorDatumArg)?.data; + if (!Array.isArray(data) || !data.length) { + return undefined; + } + const series = data[data.length - 1]; + if (!Array.isArray(series) || !series.length) { + return series; + } + const sample = series[series.length - 1]; + if (Array.isArray(sample)) { + return sample[0]; + } + return sample; + } + + private currentSettings(valuesArg: Record): Record { + return Object.fromEntries(Object.entries(valuesArg).map(([keyArg, valueArg]) => [keyArg, this.settingValue(valueArg)])); + } + + private availableSettings(valuesArg: Record): Record { + return Object.fromEntries(Object.entries(valuesArg).map(([keyArg, valueArg]) => [keyArg, Array.isArray(valueArg) ? valueArg.map((itemArg) => this.settingValue(itemArg)) : []])); + } + + private settingValue(valueArg: unknown): unknown { + if (typeof valueArg !== 'string') { + return valueArg; + } + const value = valueArg.trim(); + if (value === 'on') { + return true; + } + if (value === 'off') { + return false; + } + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value); + } + return value; + } + + private async changeSetting(keyArg: string, valueArg: unknown): Promise { + const payload = typeof valueArg === 'boolean' ? valueArg ? 'on' : 'off' : String(valueArg ?? ''); + await this.getOk(`/settings/${encodeURIComponent(keyArg)}?set=${encodeURIComponent(payload)}`, `change_setting ${keyArg}`); + this.patchCachedSetting(keyArg, valueArg); + } + + private async record(recordArg: boolean, tagArg?: string): Promise { + const tag = tagArg ? `&tag=${encodeURIComponent(tagArg)}` : ''; + await this.getOk(recordArg ? `/startvideo?force=1${tag}` : '/stopvideo?force=1', 'record'); + this.patchCachedSetting('video_recording', recordArg); + } + + private async getOk(pathArg: string, actionArg: string): Promise { + const text = await (await this.request(pathArg)).text(); + if (!text.includes('Ok')) { + throw new Error(`Android IP Webcam ${actionArg} did not return Ok.`); + } + } + + private async requestJson(pathArg: string): Promise { + const value = await (await this.request(pathArg)).json(); + return record(value) ? value as TValue : {} as TValue; + } + + private async request(pathArg: string): Promise { + const baseUrl = this.baseUrl(); + if (!baseUrl) { + throw new Error('Android IP Webcam live HTTP client requires config.host or config.url.'); + } + const headers = new Headers(); + const authorization = this.basicAuthorization(); + if (authorization) { + headers.set('authorization', authorization); + } + const response = await this.fetchWithTimeout(`${baseUrl}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`, { method: 'GET', headers }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + if (response.status === 401) { + throw new AndroidIpWebcamHttpError(response.status, 'Android IP Webcam authentication failed.'); + } + throw new AndroidIpWebcamHttpError(response.status, `Android IP Webcam request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`); + } + return response; + } + + private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || androidIpWebcamDefaultTimeoutMs); + try { + return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal }); + } finally { + clearTimeout(timeout); + } + } + + private patchCachedSetting(keyArg: string, valueArg: unknown): void { + if (!this.snapshot) { + return; + } + this.snapshot.currentSettings[keyArg] = valueArg; + for (const switchEntity of this.snapshot.switches) { + if (switchEntity.key === keyArg) { + switchEntity.isOn = Boolean(valueArg); + } + } + } + + private camera(deviceInfoArg: IAndroidIpWebcamDeviceInfo, connectedArg: boolean): IAndroidIpWebcamCamera { + return { + id: 'camera', + name: `${deviceInfoArg.name || 'Android IP Webcam'} Camera`, + mjpegUrl: this.mjpegUrl(), + imageUrl: this.imageUrl(), + rtspUrl: this.safeRtspUrl('h264', 'aac'), + audioWavUrl: this.audioUrl('audio.wav'), + audioAacUrl: this.audioUrl('audio.aac'), + audioOpusUrl: this.audioUrl('audio.opus'), + supportedFeatures: ['stream'], + available: connectedArg, + }; + } + + private deviceInfo(connectedArg: boolean): IAndroidIpWebcamDeviceInfo { + const endpoint = this.endpoint(); + return { + ...this.config.deviceInfo, + id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.url || 'manual-android-ip-webcam', + name: this.config.deviceInfo?.name || this.config.name || endpoint.host || this.config.url || 'Android IP Webcam', + manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Android IP Webcam', + model: this.config.deviceInfo?.model || this.config.model, + host: this.config.deviceInfo?.host || endpoint.host, + port: this.config.deviceInfo?.port || endpoint.port, + protocol: this.config.deviceInfo?.protocol || endpoint.protocol, + url: this.config.deviceInfo?.url || this.baseUrl(), + online: connectedArg, + }; + } + + private mjpegUrl(): string | undefined { + const baseUrl = this.baseUrl(); + return baseUrl ? `${baseUrl}/video` : undefined; + } + + private imageUrl(): string | undefined { + const baseUrl = this.baseUrl(); + return baseUrl ? `${baseUrl}/shot.jpg` : undefined; + } + + private audioUrl(pathArg: 'audio.wav' | 'audio.aac' | 'audio.opus'): string | undefined { + const baseUrl = this.baseUrl(); + return baseUrl ? `${baseUrl}/${pathArg}` : undefined; + } + + private rtspUrl(videoCodecArg: TAndroidIpWebcamRtspVideoCodec, audioCodecArg: TAndroidIpWebcamRtspAudioCodec): string | undefined { + const endpoint = this.endpoint(); + if (!endpoint.host) { + throw new Error('Android IP Webcam stream_source requires config.host or config.url.'); + } + const protocol = endpoint.protocol === 'https' ? 'rtsps' : 'rtsp'; + const credentials = this.rtspCredentials(); + return `${protocol}://${credentials}${endpoint.host}:${endpoint.port || androidIpWebcamDefaultPort}/${videoCodecArg}_${audioCodecArg}.sdp`; + } + + private safeRtspUrl(videoCodecArg: TAndroidIpWebcamRtspVideoCodec, audioCodecArg: TAndroidIpWebcamRtspAudioCodec): string | undefined { + try { + return this.rtspUrl(videoCodecArg, audioCodecArg); + } catch { + return undefined; + } + } + + private baseUrl(): string | undefined { + if (this.config.url) { + const url = safeUrl(this.config.url); + if (url) { + return url.toString().replace(/\/$/, ''); + } + } + const endpoint = this.endpoint(); + if (!endpoint.host) { + return undefined; + } + return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || androidIpWebcamDefaultPort}`; + } + + private endpoint(): { protocol: TAndroidIpWebcamProtocol; host?: string; port?: number } { + const url = safeUrl(this.config.url || this.config.host); + if (url) { + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + host: url.hostname, + port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : androidIpWebcamDefaultPort, + }; + } + return { + protocol: this.config.protocol || 'http', + host: this.config.host, + port: this.config.port || androidIpWebcamDefaultPort, + }; + } + + private basicAuthorization(): string | undefined { + if (!this.config.username || this.config.password === undefined) { + return undefined; + } + return `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`, 'utf8').toString('base64')}`; + } + + private rtspCredentials(): string { + if (!this.config.username || this.config.password === undefined) { + return ''; + } + return `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}@`; + } + + private hasLiveTarget(): boolean { + return Boolean(this.baseUrl()); + } + + private cloneSnapshot(snapshotArg: IAndroidIpWebcamSnapshot): IAndroidIpWebcamSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IAndroidIpWebcamSnapshot; + } +} + +const record = (valueArg: unknown): Record | undefined => { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; +}; + +const safeUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; diff --git a/ts/integrations/android_ip_webcam/android_ip_webcam.classes.configflow.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.configflow.ts new file mode 100644 index 0000000..3c2f86f --- /dev/null +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.configflow.ts @@ -0,0 +1,100 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IAndroidIpWebcamConfig, TAndroidIpWebcamProtocol } from './android_ip_webcam.types.js'; +import { androidIpWebcamDefaultPort, androidIpWebcamDefaultTimeoutMs } from './android_ip_webcam.types.js'; + +export class AndroidIpWebcamConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Android IP Webcam', + description: 'Configure the local Android IP Webcam HTTP endpoint. Use either a base URL such as http://192.168.1.20:8080 or host plus port.', + fields: [ + { name: 'url', label: 'Base URL', type: 'text' }, + { name: 'host', label: 'Host', type: 'text' }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + { name: 'name', label: 'Name', type: 'text' }, + ], + submit: async (valuesArg) => { + const urlValue = this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'url'); + const endpoint = this.endpoint(urlValue, this.stringValue(valuesArg.host) || candidateArg.host, this.numberValue(valuesArg.port) || candidateArg.port, this.protocolMetadata(candidateArg)); + if (!endpoint.host) { + return { kind: 'error', error: 'Android IP Webcam requires a base URL or host.' }; + } + const username = this.stringValue(valuesArg.username) || this.stringMetadata(candidateArg, 'username'); + const password = this.stringValue(valuesArg.password) || this.stringMetadata(candidateArg, 'password'); + if ((username && password === undefined) || (!username && password !== undefined)) { + return { kind: 'error', error: 'Android IP Webcam username and password must be provided together.' }; + } + + return { + kind: 'done', + title: 'Android IP Webcam configured', + config: { + protocol: endpoint.protocol, + host: endpoint.host, + port: endpoint.port, + url: endpoint.url, + username, + password, + name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host, + uniqueId: candidateArg.id || endpoint.host, + manufacturer: candidateArg.manufacturer || 'Android IP Webcam', + model: candidateArg.model, + timeoutMs: androidIpWebcamDefaultTimeoutMs, + }, + }; + }, + }; + } + + private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TAndroidIpWebcamProtocol | undefined): { protocol: TAndroidIpWebcamProtocol; host?: string; port: number; url?: string } { + const url = safeUrl(urlArg || hostArg); + if (url) { + const protocol = url.protocol === 'https:' ? 'https' : 'http'; + const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : androidIpWebcamDefaultPort; + return { protocol, host: url.hostname, port, url: url.toString().replace(/\/$/, '') }; + } + const protocol = protocolArg || 'http'; + const port = portArg || androidIpWebcamDefaultPort; + return { protocol, host: hostArg, port, url: hostArg ? `${protocol}://${hostArg}:${port}` : undefined }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number(valueArg); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } + + private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined { + const value = candidateArg.metadata?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private protocolMetadata(candidateArg: IDiscoveryCandidate): TAndroidIpWebcamProtocol | undefined { + const protocol = candidateArg.metadata?.protocol; + return protocol === 'http' || protocol === 'https' ? protocol : undefined; + } +} + +const safeUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; diff --git a/ts/integrations/android_ip_webcam/android_ip_webcam.classes.integration.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.integration.ts index e10a6c1..ad64955 100644 --- a/ts/integrations/android_ip_webcam/android_ip_webcam.classes.integration.ts +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.classes.integration.ts @@ -1,26 +1,76 @@ -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 { AndroidIpWebcamClient } from './android_ip_webcam.classes.client.js'; +import { AndroidIpWebcamConfigFlow } from './android_ip_webcam.classes.configflow.js'; +import { createAndroidIpWebcamDiscoveryDescriptor } from './android_ip_webcam.discovery.js'; +import { AndroidIpWebcamMapper } from './android_ip_webcam.mapper.js'; +import type { IAndroidIpWebcamConfig } from './android_ip_webcam.types.js'; -export class HomeAssistantAndroidIpWebcamIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "android_ip_webcam", - displayName: "Android IP Webcam", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/android_ip_webcam", - "upstreamDomain": "android_ip_webcam", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "pydroid-ipcam==3.0.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@engrbm87" - ] -}, - }); +export class AndroidIpWebcamIntegration extends BaseIntegration { + public readonly domain = 'android_ip_webcam'; + public readonly displayName = 'Android IP Webcam'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAndroidIpWebcamDiscoveryDescriptor(); + public readonly configFlow = new AndroidIpWebcamConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/android_ip_webcam', + upstreamDomain: 'android_ip_webcam', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['pydroid-ipcam==3.0.0'], + dependencies: [], + afterDependencies: [], + codeowners: ['@engrbm87'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/android_ip_webcam', + nativePort: { + snapshotMapping: true, + manualHostUrlDiscovery: true, + liveHttpCommands: true, + liveEvents: false, + }, + }; + + public async setup(configArg: IAndroidIpWebcamConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AndroidIpWebcamRuntime(new AndroidIpWebcamClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAndroidIpWebcamIntegration extends AndroidIpWebcamIntegration {} + +class AndroidIpWebcamRuntime implements IIntegrationRuntime { + public domain = 'android_ip_webcam'; + + constructor(private readonly client: AndroidIpWebcamClient) {} + + public async devices(): Promise { + return AndroidIpWebcamMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AndroidIpWebcamMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + const snapshot = await this.client.getSnapshot(); + const command = AndroidIpWebcamMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Android IP Webcam service: ${requestArg.domain}.${requestArg.service}` }; + } + const data = await this.client.execute(command); + return { success: true, data }; + } 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/android_ip_webcam/android_ip_webcam.discovery.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.discovery.ts new file mode 100644 index 0000000..aaa91e5 --- /dev/null +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.discovery.ts @@ -0,0 +1,146 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IAndroidIpWebcamManualEntry, TAndroidIpWebcamProtocol } from './android_ip_webcam.types.js'; +import { androidIpWebcamDefaultPort } from './android_ip_webcam.types.js'; + +export class AndroidIpWebcamManualMatcher implements IDiscoveryMatcher { + public id = 'android-ip-webcam-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Android IP Webcam host or base URL entries.'; + + public async matches(inputArg: IAndroidIpWebcamManualEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const endpoint = endpointFromInput(inputArg); + const hasHint = Boolean(endpoint.host || inputArg.metadata?.androidIpWebcam || inputArg.metadata?.android_ip_webcam); + if (!hasHint) { + return { matched: false, confidence: 'low', reason: 'Manual Android IP Webcam entry requires host, url, or android_ip_webcam metadata.' }; + } + + const normalizedDeviceId = inputArg.id || endpoint.host || endpoint.url; + return { + matched: true, + confidence: endpoint.host ? 'high' : 'medium', + reason: endpoint.url ? 'Manual entry contains an Android IP Webcam base URL.' : 'Manual entry contains an Android IP Webcam host.', + normalizedDeviceId, + candidate: { + source: 'manual', + integrationDomain: 'android_ip_webcam', + id: normalizedDeviceId, + host: endpoint.host, + port: endpoint.port, + name: inputArg.name || endpoint.host, + manufacturer: inputArg.manufacturer || 'Android IP Webcam', + model: inputArg.model, + metadata: { + ...inputArg.metadata, + protocol: endpoint.protocol, + url: endpoint.url, + username: inputArg.username, + password: inputArg.password, + discoveryProtocol: 'manual', + }, + }, + metadata: { + protocol: endpoint.protocol, + url: endpoint.url, + }, + }; + } +} + +export class AndroidIpWebcamCandidateValidator implements IDiscoveryValidator { + public id = 'android-ip-webcam-candidate-validator'; + public description = 'Validate that a discovery candidate has a usable Android IP Webcam host or URL.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'android_ip_webcam') { + return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Android IP Webcam.` }; + } + const endpoint = endpointFromCandidate(candidateArg); + if (!endpoint.host) { + return { matched: false, confidence: 'low', reason: 'Android IP Webcam candidates require a host or URL.' }; + } + if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) { + return { matched: false, confidence: 'low', reason: 'Android IP Webcam candidate has an invalid port.' }; + } + return { + matched: true, + confidence: candidateArg.source === 'manual' ? 'high' : 'medium', + reason: 'Candidate has enough Android IP Webcam metadata to start configuration.', + normalizedDeviceId: candidateArg.id || endpoint.host, + candidate: { + ...candidateArg, + integrationDomain: 'android_ip_webcam', + host: endpoint.host, + port: endpoint.port, + manufacturer: candidateArg.manufacturer || 'Android IP Webcam', + metadata: { + ...candidateArg.metadata, + protocol: endpoint.protocol, + url: endpoint.url, + }, + }, + metadata: { + manualSupported: candidateArg.source === 'manual', + protocol: endpoint.protocol, + url: endpoint.url, + }, + }; + } +} + +export const createAndroidIpWebcamDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'android_ip_webcam', displayName: 'Android IP Webcam' }) + .addMatcher(new AndroidIpWebcamManualMatcher()) + .addValidator(new AndroidIpWebcamCandidateValidator()); +}; + +const endpointFromInput = (inputArg: IAndroidIpWebcamManualEntry): { protocol: TAndroidIpWebcamProtocol; host?: string; port: number; url?: string } => { + const url = safeUrl(inputArg.url || inputArg.host); + if (url) { + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + host: url.hostname, + port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : androidIpWebcamDefaultPort, + url: url.toString().replace(/\/$/, ''), + }; + } + return { + protocol: inputArg.protocol || 'http', + host: inputArg.host, + port: inputArg.port || androidIpWebcamDefaultPort, + url: inputArg.host ? `${inputArg.protocol || 'http'}://${inputArg.host}:${inputArg.port || androidIpWebcamDefaultPort}` : undefined, + }; +}; + +const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TAndroidIpWebcamProtocol; host?: string; port: number; url?: string } => { + const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined; + const metadataProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http'; + const url = safeUrl(metadataUrl || candidateArg.host); + if (url) { + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + host: url.hostname, + port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : androidIpWebcamDefaultPort, + url: url.toString().replace(/\/$/, ''), + }; + } + return { + protocol: metadataProtocol, + host: candidateArg.host, + port: candidateArg.port || androidIpWebcamDefaultPort, + url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${candidateArg.port || androidIpWebcamDefaultPort}` : metadataUrl, + }; +}; + +const safeUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; diff --git a/ts/integrations/android_ip_webcam/android_ip_webcam.mapper.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.mapper.ts new file mode 100644 index 0000000..952f500 --- /dev/null +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.mapper.ts @@ -0,0 +1,331 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IAndroidIpWebcamClientCommand, + IAndroidIpWebcamSensor, + IAndroidIpWebcamSnapshot, + IAndroidIpWebcamSwitch, + TAndroidIpWebcamOrientation, + TAndroidIpWebcamRtspAudioCodec, + TAndroidIpWebcamRtspVideoCodec, +} from './android_ip_webcam.types.js'; +import { androidIpWebcamSwitchDescriptions } from './android_ip_webcam.types.js'; + +const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']); +const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']); +const directSettingServices = new Set(['change_setting', 'set_setting']); +const serviceBooleanKeys = ['activate', 'on', 'enabled', 'state', 'value']; + +export class AndroidIpWebcamMapper { + public static toDevices(snapshotArg: IAndroidIpWebcamSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'camera', capability: 'camera', name: snapshotArg.camera.name || 'Camera', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + { featureId: 'camera', value: { mjpegUrl: snapshotArg.camera.mjpegUrl || null, imageUrl: snapshotArg.camera.imageUrl || null, rtspUrl: snapshotArg.camera.rtspUrl || null }, updatedAt }, + ]; + + for (const sensor of snapshotArg.sensors) { + features.push({ id: `sensor_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit }); + state.push({ featureId: `sensor_${this.slug(sensor.key)}`, value: this.deviceStateValue(sensor.value), updatedAt }); + } + for (const sensor of snapshotArg.binarySensors) { + features.push({ id: `binary_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false }); + state.push({ featureId: `binary_${this.slug(sensor.key)}`, value: sensor.isOn, updatedAt }); + } + for (const setting of snapshotArg.switches) { + features.push({ id: `setting_${this.slug(setting.key)}`, capability: 'switch', name: setting.name, readable: true, writable: true }); + state.push({ featureId: `setting_${this.slug(setting.key)}`, value: setting.isOn, updatedAt }); + } + + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'android_ip_webcam', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Android IP Webcam', + model: snapshotArg.deviceInfo.model, + online: snapshotArg.connected, + features, + state, + metadata: { + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + protocol: snapshotArg.deviceInfo.protocol, + url: snapshotArg.deviceInfo.url, + enabledSensors: snapshotArg.enabledSensors, + enabledSettings: snapshotArg.enabledSettings, + availableSettings: snapshotArg.availableSettings, + camera: { + mjpegUrl: snapshotArg.camera.mjpegUrl, + imageUrl: snapshotArg.camera.imageUrl, + rtspUrl: snapshotArg.camera.rtspUrl, + }, + }, + }]; + } + + public static toEntities(snapshotArg: IAndroidIpWebcamSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const deviceId = this.deviceId(snapshotArg); + const baseName = this.deviceName(snapshotArg); + + entities.push(this.entity('camera' as TEntityPlatform, snapshotArg.camera.name || `${baseName} Camera`, deviceId, `android_ip_webcam_${this.uniqueBase(snapshotArg)}_camera`, snapshotArg.connected ? 'idle' : 'unavailable', usedIds, { + mjpegUrl: snapshotArg.camera.mjpegUrl, + stillImageUrl: snapshotArg.camera.imageUrl, + streamSource: snapshotArg.camera.rtspUrl, + audioWavUrl: snapshotArg.camera.audioWavUrl, + audioAacUrl: snapshotArg.camera.audioAacUrl, + audioOpusUrl: snapshotArg.camera.audioOpusUrl, + supportedFeatures: snapshotArg.camera.supportedFeatures || ['stream'], + serviceMappings: { + snapshot: 'camera.snapshot', + streamSource: 'camera.stream_source', + }, + ...snapshotArg.camera.attributes, + }, snapshotArg.connected && snapshotArg.camera.available !== false)); + + for (const sensor of snapshotArg.sensors) { + entities.push(this.sensorEntity(sensor, deviceId, snapshotArg, usedIds)); + } + for (const sensor of snapshotArg.binarySensors) { + entities.push(this.entity('binary_sensor', sensor.name, deviceId, `android_ip_webcam_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.isOn ? 'on' : 'off', usedIds, { + key: sensor.key, + deviceClass: sensor.deviceClass, + ...sensor.attributes, + }, snapshotArg.connected && sensor.available !== false)); + } + for (const setting of snapshotArg.switches) { + entities.push(this.entity('switch', setting.name, deviceId, `android_ip_webcam_${this.uniqueBase(snapshotArg)}_${this.slug(setting.key)}`, setting.isOn ? 'on' : 'off', usedIds, { + key: setting.key, + command: setting.command, + entityCategory: setting.entityCategory, + ...setting.attributes, + }, snapshotArg.connected && setting.available !== false)); + } + + return entities; + } + + public static commandForService(snapshotArg: IAndroidIpWebcamSnapshot, requestArg: IServiceCallRequest): IAndroidIpWebcamClientCommand | undefined { + if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) { + return { + type: 'stream_source', + service: requestArg.service, + target: requestArg.target, + data: requestArg.data, + videoCodec: this.videoCodec(requestArg.data?.video_codec ?? requestArg.data?.videoCodec) || 'h264', + audioCodec: this.audioCodec(requestArg.data?.audio_codec ?? requestArg.data?.audioCodec) || 'aac', + }; + } + if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) { + return { + type: 'snapshot_image', + service: requestArg.service, + target: requestArg.target, + data: requestArg.data, + filename: this.stringValue(requestArg.data?.filename), + }; + } + if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) { + const setting = this.findSwitch(snapshotArg, requestArg); + if (!setting) { + return undefined; + } + const activate = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !setting.isOn; + return this.commandForSwitch(setting, requestArg, activate); + } + if (requestArg.domain === 'android_ip_webcam') { + return this.androidIpWebcamCommand(snapshotArg, requestArg); + } + return undefined; + } + + public static deviceId(snapshotArg: IAndroidIpWebcamSnapshot): string { + return `android_ip_webcam.device.${this.uniqueBase(snapshotArg)}`; + } + + private static sensorEntity(sensorArg: IAndroidIpWebcamSensor, deviceIdArg: string, snapshotArg: IAndroidIpWebcamSnapshot, usedIdsArg: Map): IIntegrationEntity { + return this.entity('sensor', sensorArg.name, deviceIdArg, `android_ip_webcam_${this.uniqueBase(snapshotArg)}_${this.slug(sensorArg.key)}`, sensorArg.value ?? 'unknown', usedIdsArg, { + key: sensorArg.key, + unit: sensorArg.unit, + deviceClass: sensorArg.deviceClass, + stateClass: sensorArg.stateClass, + entityCategory: sensorArg.entityCategory, + ...sensorArg.attributes, + }, snapshotArg.connected && sensorArg.available !== false); + } + + private static androidIpWebcamCommand(snapshotArg: IAndroidIpWebcamSnapshot, requestArg: IServiceCallRequest): IAndroidIpWebcamClientCommand | undefined { + if (requestArg.service === 'refresh') { + return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data }; + } + if (cameraStreamServices.has(requestArg.service)) { + return { type: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, videoCodec: this.videoCodec(requestArg.data?.video_codec ?? requestArg.data?.videoCodec) || 'h264', audioCodec: this.audioCodec(requestArg.data?.audio_codec ?? requestArg.data?.audioCodec) || 'aac' }; + } + if (cameraSnapshotServices.has(requestArg.service)) { + return { type: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, filename: this.stringValue(requestArg.data?.filename) }; + } + if (directSettingServices.has(requestArg.service)) { + const key = this.stringValue(requestArg.data?.key ?? requestArg.data?.setting); + if (!key || requestArg.data?.value === undefined) { + return undefined; + } + return { type: 'setting', service: requestArg.service, target: requestArg.target, data: requestArg.data, key, value: requestArg.data.value }; + } + if (requestArg.service === 'set_zoom') { + const zoom = this.numberValue(requestArg.data?.zoom); + return zoom === undefined ? undefined : { type: 'set_zoom', service: requestArg.service, target: requestArg.target, data: requestArg.data, zoom }; + } + if (requestArg.service === 'set_quality') { + const quality = this.numberValue(requestArg.data?.quality); + return quality === undefined ? undefined : { type: 'set_quality', service: requestArg.service, target: requestArg.target, data: requestArg.data, quality }; + } + if (requestArg.service === 'set_orientation') { + const orientation = this.orientationValue(requestArg.data?.orientation); + return orientation ? { type: 'set_orientation', service: requestArg.service, target: requestArg.target, data: requestArg.data, orientation } : undefined; + } + if (requestArg.service === 'set_scenemode') { + const scenemode = this.stringValue(requestArg.data?.scenemode ?? requestArg.data?.scene_mode); + return scenemode ? { type: 'set_scenemode', service: requestArg.service, target: requestArg.target, data: requestArg.data, scenemode } : undefined; + } + + const settingKey = requestArg.service.startsWith('set_') ? requestArg.service.slice(4) : requestArg.service; + const description = androidIpWebcamSwitchDescriptions.find((descriptionArg) => descriptionArg.key === settingKey); + if (!description) { + return undefined; + } + const snapshotSwitch = snapshotArg.switches.find((switchArg) => switchArg.key === description.key) || { ...description, isOn: false, available: true } as IAndroidIpWebcamSwitch; + const activate = this.booleanFromData(requestArg.data) ?? true; + return this.commandForSwitch(snapshotSwitch, requestArg, activate); + } + + private static commandForSwitch(settingArg: IAndroidIpWebcamSwitch, requestArg: IServiceCallRequest, activateArg: boolean): IAndroidIpWebcamClientCommand { + if (settingArg.command === 'torch') { + return { type: 'torch', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: settingArg.key, activate: activateArg }; + } + if (settingArg.command === 'focus') { + return { type: 'focus', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: settingArg.key, activate: activateArg }; + } + if (settingArg.command === 'record') { + return { type: 'record', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: settingArg.key, record: activateArg, tag: this.stringValue(requestArg.data?.tag) }; + } + return { type: 'setting', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: settingArg.key, value: activateArg }; + } + + private static findSwitch(snapshotArg: IAndroidIpWebcamSnapshot, requestArg: IServiceCallRequest): IAndroidIpWebcamSwitch | undefined { + const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key ?? requestArg.data?.setting); + if (!target) { + return snapshotArg.switches[0]; + } + const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch'); + const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target); + const key = this.stringValue(entity?.attributes?.key) || target; + return snapshotArg.switches.find((switchArg) => switchArg.key === key || switchArg.name === target || `android_ip_webcam.device.${this.uniqueBase(snapshotArg)}` === target); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record, availableArg: boolean): IIntegrationEntity { + const baseId = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return { + id: seen ? `${baseId}_${seen + 1}` : baseId, + uniqueId: uniqueIdArg, + integrationDomain: 'android_ip_webcam', + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static deviceName(snapshotArg: IAndroidIpWebcamSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Android IP Webcam'; + } + + private static uniqueBase(snapshotArg: IAndroidIpWebcamSnapshot): string { + return this.slug(snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || snapshotArg.deviceInfo.url || this.deviceName(snapshotArg)); + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (valueArg === undefined) { + return null; + } + if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') { + return valueArg; + } + if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) { + return valueArg as Record; + } + return String(valueArg); + } + + private static booleanFromData(dataArg: Record | undefined): boolean | undefined { + for (const key of serviceBooleanKeys) { + const value = this.booleanValue(dataArg?.[key]); + if (value !== undefined) { + return value; + } + } + return undefined; + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) { + return true; + } + if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) { + return false; + } + } + 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 value = Number(valueArg); + return Number.isFinite(value) ? value : undefined; + } + return undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined; + } + + private static orientationValue(valueArg: unknown): TAndroidIpWebcamOrientation | undefined { + const value = this.stringValue(valueArg); + return value === 'landscape' || value === 'upsidedown' || value === 'portrait' || value === 'upsidedown_portrait' ? value : undefined; + } + + private static videoCodec(valueArg: unknown): TAndroidIpWebcamRtspVideoCodec | undefined { + const value = this.stringValue(valueArg); + return value === 'jpeg' || value === 'h264' ? value : undefined; + } + + private static audioCodec(valueArg: unknown): TAndroidIpWebcamRtspAudioCodec | undefined { + const value = this.stringValue(valueArg); + return value === 'ulaw' || value === 'alaw' || value === 'pcm' || value === 'opus' || value === 'aac' ? value : undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'android_ip_webcam'; + } +} diff --git a/ts/integrations/android_ip_webcam/android_ip_webcam.types.ts b/ts/integrations/android_ip_webcam/android_ip_webcam.types.ts index d30df89..8fa6255 100644 --- a/ts/integrations/android_ip_webcam/android_ip_webcam.types.ts +++ b/ts/integrations/android_ip_webcam/android_ip_webcam.types.ts @@ -1,4 +1,222 @@ -export interface IHomeAssistantAndroidIpWebcamConfig { - // TODO: replace with the TypeScript-native config for android_ip_webcam. +export const androidIpWebcamDefaultPort = 8080; +export const androidIpWebcamDefaultTimeoutMs = 10000; + +export type TAndroidIpWebcamProtocol = 'http' | 'https'; +export type TAndroidIpWebcamRtspVideoCodec = 'jpeg' | 'h264'; +export type TAndroidIpWebcamRtspAudioCodec = 'ulaw' | 'alaw' | 'pcm' | 'opus' | 'aac'; +export type TAndroidIpWebcamOrientation = 'landscape' | 'upsidedown' | 'portrait' | 'upsidedown_portrait'; +export type TAndroidIpWebcamSettingCommand = 'setting' | 'torch' | 'focus' | 'record'; +export type TAndroidIpWebcamCommandType = + | 'snapshot_image' + | 'stream_source' + | 'setting' + | 'torch' + | 'focus' + | 'record' + | 'set_zoom' + | 'set_quality' + | 'set_orientation' + | 'set_scenemode' + | 'refresh'; + +export interface IAndroidIpWebcamConfig { + protocol?: TAndroidIpWebcamProtocol; + host?: string; + port?: number; + url?: string; + username?: string; + password?: string; + timeoutMs?: number; + name?: string; + uniqueId?: string; + manufacturer?: string; + model?: string; + connected?: boolean; + deviceInfo?: IAndroidIpWebcamDeviceInfo; + camera?: IAndroidIpWebcamCamera; + sensors?: IAndroidIpWebcamSensor[]; + binarySensors?: IAndroidIpWebcamBinarySensor[]; + switches?: IAndroidIpWebcamSwitch[]; + statusData?: IAndroidIpWebcamStatusData; + sensorData?: IAndroidIpWebcamSensorData; + currentSettings?: Record; + enabledSensors?: string[]; + enabledSettings?: string[]; + availableSettings?: Record; + snapshot?: IAndroidIpWebcamSnapshot; +} + +export interface IHomeAssistantAndroidIpWebcamConfig extends IAndroidIpWebcamConfig {} + +export interface IAndroidIpWebcamDeviceInfo { + id?: string; + name?: string; + manufacturer?: string; + model?: string; + host?: string; + port?: number; + protocol?: TAndroidIpWebcamProtocol; + url?: string; + online?: boolean; +} + +export interface IAndroidIpWebcamCamera { + id: string; + name?: string; + mjpegUrl?: string; + imageUrl?: string; + rtspUrl?: string; + audioWavUrl?: string; + audioAacUrl?: string; + audioOpusUrl?: string; + supportedFeatures?: string[]; + available?: boolean; + attributes?: Record; +} + +export interface IAndroidIpWebcamSensor { + key: string; + name: string; + value: TValue; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAndroidIpWebcamBinarySensor { + key: string; + name: string; + isOn: boolean; + deviceClass?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAndroidIpWebcamSwitch { + key: string; + name: string; + isOn: boolean; + command: TAndroidIpWebcamSettingCommand; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAndroidIpWebcamStatusData { + curvals?: Record; + avail?: Record; + audio_connections?: unknown; + video_connections?: unknown; [key: string]: unknown; } + +export interface IAndroidIpWebcamSensorDatum { + unit?: string; + data?: unknown; + [key: string]: unknown; +} + +export type IAndroidIpWebcamSensorData = Record; + +export interface IAndroidIpWebcamSnapshot { + deviceInfo: IAndroidIpWebcamDeviceInfo; + camera: IAndroidIpWebcamCamera; + sensors: IAndroidIpWebcamSensor[]; + binarySensors: IAndroidIpWebcamBinarySensor[]; + switches: IAndroidIpWebcamSwitch[]; + statusData: IAndroidIpWebcamStatusData; + sensorData: IAndroidIpWebcamSensorData; + currentSettings: Record; + enabledSensors: string[]; + enabledSettings: string[]; + availableSettings: Record; + connected: boolean; + updatedAt?: string; + metadata?: Record; +} + +export interface IAndroidIpWebcamSensorDescription { + key: string; + name: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + statusKey?: 'audio_connections' | 'video_connections'; +} + +export interface IAndroidIpWebcamSwitchDescription { + key: string; + name: string; + command: TAndroidIpWebcamSettingCommand; + entityCategory?: string; +} + +export interface IAndroidIpWebcamClientCommand { + type: TAndroidIpWebcamCommandType; + service: string; + target?: { + entityId?: string; + deviceId?: string; + }; + data?: Record; + key?: string; + value?: unknown; + activate?: boolean; + record?: boolean; + tag?: string; + zoom?: number; + quality?: number; + orientation?: TAndroidIpWebcamOrientation; + scenemode?: string; + videoCodec?: TAndroidIpWebcamRtspVideoCodec; + audioCodec?: TAndroidIpWebcamRtspAudioCodec; + filename?: string; +} + +export interface IAndroidIpWebcamSnapshotImage { + contentType: string; + data: Uint8Array; +} + +export interface IAndroidIpWebcamManualEntry { + host?: string; + port?: number; + url?: string; + protocol?: TAndroidIpWebcamProtocol; + username?: string; + password?: string; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export const androidIpWebcamSensorDescriptions: IAndroidIpWebcamSensorDescription[] = [ + { key: 'audio_connections', name: 'Audio connections', stateClass: 'total', entityCategory: 'diagnostic', statusKey: 'audio_connections' }, + { key: 'battery_level', name: 'Battery level', deviceClass: 'battery', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'battery_temp', name: 'Battery temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'battery_voltage', name: 'Battery voltage', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'light', name: 'Light level', stateClass: 'measurement' }, + { key: 'motion', name: 'Motion', stateClass: 'measurement' }, + { key: 'pressure', name: 'Pressure', stateClass: 'measurement' }, + { key: 'proximity', name: 'Proximity', stateClass: 'measurement' }, + { key: 'sound', name: 'Sound', stateClass: 'measurement' }, + { key: 'video_connections', name: 'Video connections', stateClass: 'total', entityCategory: 'diagnostic', statusKey: 'video_connections' }, +]; + +export const androidIpWebcamSwitchDescriptions: IAndroidIpWebcamSwitchDescription[] = [ + { key: 'exposure_lock', name: 'Exposure lock', command: 'setting', entityCategory: 'config' }, + { key: 'ffc', name: 'Front-facing camera', command: 'setting', entityCategory: 'config' }, + { key: 'focus', name: 'Focus', command: 'focus', entityCategory: 'config' }, + { key: 'gps_active', name: 'GPS active', command: 'setting', entityCategory: 'config' }, + { key: 'motion_detect', name: 'Motion detection', command: 'setting', entityCategory: 'config' }, + { key: 'night_vision', name: 'Night vision', command: 'setting', entityCategory: 'config' }, + { key: 'overlay', name: 'Overlay', command: 'setting', entityCategory: 'config' }, + { key: 'torch', name: 'Torch', command: 'torch', entityCategory: 'config' }, + { key: 'whitebalance_lock', name: 'White balance lock', command: 'setting', entityCategory: 'config' }, + { key: 'video_recording', name: 'Video recording', command: 'record', entityCategory: 'config' }, +]; diff --git a/ts/integrations/android_ip_webcam/index.ts b/ts/integrations/android_ip_webcam/index.ts index a66b55f..b05055d 100644 --- a/ts/integrations/android_ip_webcam/index.ts +++ b/ts/integrations/android_ip_webcam/index.ts @@ -1,2 +1,6 @@ +export * from './android_ip_webcam.classes.client.js'; +export * from './android_ip_webcam.classes.configflow.js'; export * from './android_ip_webcam.classes.integration.js'; +export * from './android_ip_webcam.discovery.js'; +export * from './android_ip_webcam.mapper.js'; export * from './android_ip_webcam.types.js'; diff --git a/ts/integrations/apcupsd/.generated-by-smarthome-exchange b/ts/integrations/apcupsd/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/apcupsd/.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/apcupsd/apcupsd.classes.client.ts b/ts/integrations/apcupsd/apcupsd.classes.client.ts new file mode 100644 index 0000000..8995230 --- /dev/null +++ b/ts/integrations/apcupsd/apcupsd.classes.client.ts @@ -0,0 +1,289 @@ +import * as plugins from '../../plugins.js'; +import type { IApcupsdConfig, IApcupsdSnapshot, IApcupsdStatusRecord, TApcupsdSnapshotSource } from './apcupsd.types.js'; +import { apcupsdDefaultPort, apcupsdDefaultTimeoutMs } from './apcupsd.types.js'; + +const statusCommand = 'status'; +const onlineStatusMask = 0b1000; + +export class ApcupsdClient { + constructor(private readonly config: IApcupsdConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot'); + } + + if (this.config.rawStatus) { + return this.normalizeSnapshot(this.snapshotFromStatus(this.config.rawStatus, this.config.online ?? true, 'manual'), 'manual'); + } + + if (this.config.host) { + try { + const raw = await this.requestStatusText(); + return this.normalizeSnapshot(this.snapshotFromStatus(raw, true, 'tcp'), 'tcp'); + } catch (errorArg) { + return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime'); + } + } + + return this.normalizeSnapshot(this.snapshotFromConfig(this.config.online ?? false), 'runtime'); + } + + public async refresh(): Promise { + return this.getSnapshot(); + } + + public async ping(): Promise { + if (!this.config.host) { + return Boolean(this.config.snapshot || this.config.rawStatus); + } + const snapshot = this.normalizeSnapshot(this.snapshotFromStatus(await this.requestStatusText(), true, 'tcp'), 'tcp'); + return snapshot.online; + } + + public async destroy(): Promise {} + + public static parseStatusText(textArg: string): IApcupsdStatusRecord { + const record: IApcupsdStatusRecord = {}; + for (const line of textArg.split(/\r?\n/)) { + const match = /^([^:]+?)\s*:\s*(.*)$/.exec(line); + if (!match) { + continue; + } + const key = match[1].trim().toUpperCase(); + if (!key) { + continue; + } + record[key] = match[2].trim(); + } + return record; + } + + public static normalizeStatusRecord(statusArg: string | IApcupsdStatusRecord): IApcupsdStatusRecord { + if (typeof statusArg === 'string') { + return this.parseStatusText(statusArg); + } + const record: IApcupsdStatusRecord = {}; + for (const [key, value] of Object.entries(statusArg)) { + if (typeof value === 'string') { + record[key.trim().toUpperCase()] = value.trim(); + } + } + return record; + } + + public static numberFromValue(valueArg: string | undefined): number | undefined { + if (!valueArg) { + return undefined; + } + const match = /[-+]?\d+(?:\.\d+)?/.exec(valueArg); + if (!match) { + return undefined; + } + const parsed = Number(match[0]); + return Number.isFinite(parsed) ? parsed : undefined; + } + + public static statusFlag(valueArg: string | undefined): number | undefined { + if (!valueArg) { + return undefined; + } + const match = /0x[0-9a-f]+|\d+/i.exec(valueArg); + if (!match) { + return undefined; + } + const parsed = match[0].toLowerCase().startsWith('0x') ? Number.parseInt(match[0], 16) : Number(match[0]); + return Number.isFinite(parsed) ? parsed : undefined; + } + + private async requestStatusText(): Promise { + const host = this.config.host; + if (!host) { + throw new Error('APCUPSd TCP status requires config.host.'); + } + const port = this.config.port || apcupsdDefaultPort; + const timeoutMs = this.config.timeoutMs || apcupsdDefaultTimeoutMs; + + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + let settled = false; + const chunks: string[] = []; + const socket = plugins.net.createConnection({ host, port }); + + const finish = (errorArg?: Error, valueArg?: string) => { + if (settled) { + return; + } + settled = true; + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + reject(errorArg); + return; + } + resolve(valueArg || ''); + }; + + const handleData = (chunkArg: Buffer) => { + buffer = Buffer.concat([buffer, chunkArg]); + while (buffer.length >= 2) { + const length = buffer.readUInt16BE(0); + if (length === 0) { + finish(undefined, chunks.join('')); + return; + } + if (buffer.length < length + 2) { + return; + } + chunks.push(buffer.subarray(2, 2 + length).toString('utf8')); + buffer = buffer.subarray(2 + length); + } + }; + + socket.setTimeout(timeoutMs, () => finish(new Error(`APCUPSd TCP status request timed out after ${timeoutMs}ms.`))); + socket.on('connect', () => socket.write(this.frame(statusCommand))); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => finish(new Error('APCUPSd TCP connection closed before status response completed.'))); + socket.on('data', (chunkArg) => handleData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg))); + }); + } + + private frame(valueArg: string): Buffer { + const payload = Buffer.from(valueArg, 'utf8'); + const frame = Buffer.alloc(payload.length + 2); + frame.writeUInt16BE(payload.length, 0); + payload.copy(frame, 2); + return frame; + } + + private snapshotFromStatus(statusArg: string | IApcupsdStatusRecord, onlineArg: boolean, sourceArg: TApcupsdSnapshotSource): IApcupsdSnapshot { + const status = ApcupsdClient.normalizeStatusRecord(statusArg); + const statusFlag = ApcupsdClient.statusFlag(status.STATFLAG); + const serialNumber = status.SERIALNO && status.SERIALNO !== 'Blank' ? status.SERIALNO : undefined; + const model = status.APCMODEL || status.MODEL; + const lineOnline = this.lineOnline(status, statusFlag); + const host = this.config.host; + const port = this.config.port || (host ? apcupsdDefaultPort : undefined); + const id = this.config.uniqueId || serialNumber || (host ? `${host}:${port}` : undefined) || status.UPSNAME || model; + + return { + ups: { + id, + name: this.config.name || status.UPSNAME || model || 'APC UPS', + host, + port, + model, + serialNumber, + firmware: status.FIRMWARE, + version: status.VERSION, + hostname: status.HOSTNAME, + mode: status.UPSMODE, + status: status.STATUS, + statusFlag, + lineOnline, + }, + battery: { + chargePercent: ApcupsdClient.numberFromValue(status.BCHARGE), + voltage: ApcupsdClient.numberFromValue(status.BATTV), + nominalVoltage: ApcupsdClient.numberFromValue(status.NOMBATTV), + timeLeftMinutes: ApcupsdClient.numberFromValue(status.TIMELEFT), + timeOnBatterySeconds: ApcupsdClient.numberFromValue(status.TONBATT), + totalTimeOnBatterySeconds: ApcupsdClient.numberFromValue(status.CUMONBATT), + status: status.BATTSTAT, + replacementDate: status.BATTDATE, + lastSelfTest: status.LASTSTEST, + selfTest: status.SELFTEST, + badBatteries: ApcupsdClient.numberFromValue(status.BADBATTS), + externalBatteries: ApcupsdClient.numberFromValue(status.EXTBATTS), + }, + power: { + lineVoltage: ApcupsdClient.numberFromValue(status.LINEV), + outputVoltage: ApcupsdClient.numberFromValue(status.OUTPUTV), + loadPercent: ApcupsdClient.numberFromValue(status.LOADPCT), + loadApparentPercent: ApcupsdClient.numberFromValue(status.LOADAPNT), + lineFrequency: ApcupsdClient.numberFromValue(status.LINEFREQ), + nominalInputVoltage: ApcupsdClient.numberFromValue(status.NOMINV), + nominalOutputVoltage: ApcupsdClient.numberFromValue(status.NOMOUTV), + nominalPowerWatts: ApcupsdClient.numberFromValue(status.NOMPOWER), + nominalApparentPowerVa: ApcupsdClient.numberFromValue(status.NOMAPNT), + outputCurrentAmps: ApcupsdClient.numberFromValue(status.OUTCURNT), + inputVoltageHigh: ApcupsdClient.numberFromValue(status.MAXLINEV), + inputVoltageLow: ApcupsdClient.numberFromValue(status.MINLINEV), + transferHighVoltage: ApcupsdClient.numberFromValue(status.HITRANS), + transferLowVoltage: ApcupsdClient.numberFromValue(status.LOTRANS), + transferCount: ApcupsdClient.numberFromValue(status.NUMXFERS), + lastTransfer: status.LASTXFER, + }, + status, + online: onlineArg, + updatedAt: new Date().toISOString(), + source: sourceArg, + raw: typeof statusArg === 'string' ? statusArg : undefined, + }; + } + + private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IApcupsdSnapshot { + const host = this.config.host; + const port = this.config.port || (host ? apcupsdDefaultPort : undefined); + return { + ups: { + id: this.config.uniqueId || (host ? `${host}:${port}` : undefined) || this.config.name || 'apc-ups', + name: this.config.name || 'APC UPS', + host, + port, + }, + battery: {}, + power: {}, + status: {}, + online: onlineArg, + updatedAt: new Date().toISOString(), + source: 'runtime', + error: errorArg, + }; + } + + private normalizeSnapshot(snapshotArg: IApcupsdSnapshot, sourceArg: TApcupsdSnapshotSource): IApcupsdSnapshot { + const status = ApcupsdClient.normalizeStatusRecord(snapshotArg.status || {}); + const statusDerived = Object.keys(status).length ? this.snapshotFromStatus(status, snapshotArg.online, sourceArg) : undefined; + const ups = { + ...statusDerived?.ups, + ...snapshotArg.ups, + }; + ups.host = ups.host || this.config.host; + ups.port = ups.port || (ups.host ? this.config.port || apcupsdDefaultPort : this.config.port); + ups.name = ups.name || this.config.name || ups.model || 'APC UPS'; + ups.id = ups.id || this.config.uniqueId || ups.serialNumber || (ups.host ? `${ups.host}:${ups.port || apcupsdDefaultPort}` : undefined) || ups.name; + + return { + ...snapshotArg, + ups, + battery: { ...statusDerived?.battery, ...snapshotArg.battery }, + power: { ...statusDerived?.power, ...snapshotArg.power }, + status, + online: snapshotArg.online, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + source: snapshotArg.source || sourceArg, + }; + } + + private lineOnline(statusArg: IApcupsdStatusRecord, statusFlagArg: number | undefined): boolean | undefined { + if (statusFlagArg !== undefined) { + return (statusFlagArg & onlineStatusMask) !== 0; + } + const status = statusArg.STATUS?.toUpperCase(); + if (!status) { + return undefined; + } + if (status.includes('ONBATT') || status.includes('ON BATTERY')) { + return false; + } + if (status.includes('ONLINE') || status.includes('ON LINE') || status.includes('BOOST') || status.includes('TRIM')) { + return true; + } + return undefined; + } + + private cloneSnapshot(snapshotArg: IApcupsdSnapshot): IApcupsdSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IApcupsdSnapshot; + } +} diff --git a/ts/integrations/apcupsd/apcupsd.classes.configflow.ts b/ts/integrations/apcupsd/apcupsd.classes.configflow.ts new file mode 100644 index 0000000..2052077 --- /dev/null +++ b/ts/integrations/apcupsd/apcupsd.classes.configflow.ts @@ -0,0 +1,53 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IApcupsdConfig } from './apcupsd.types.js'; +import { apcupsdDefaultPort, apcupsdDefaultTimeoutMs } from './apcupsd.types.js'; + +export class ApcupsdConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect APC UPS Daemon', + description: 'Configure the local APCUPSd Network Information Server endpoint.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'TCP port', type: 'number' }, + { name: 'name', label: 'Name', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || candidateArg.host || ''; + if (!host) { + return { kind: 'error', title: 'APCUPSd setup failed', error: 'APCUPSd host is required.' }; + } + const port = this.numberValue(valuesArg.port) || candidateArg.port || apcupsdDefaultPort; + return { + kind: 'done', + title: 'APCUPSd configured', + config: { + host, + port, + name: this.stringValue(valuesArg.name) || candidateArg.name, + uniqueId: candidateArg.id || `${host}:${port}`, + transport: 'tcp', + timeoutMs: apcupsdDefaultTimeoutMs, + }, + }; + }, + }; + } + + 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; + } +} diff --git a/ts/integrations/apcupsd/apcupsd.classes.integration.ts b/ts/integrations/apcupsd/apcupsd.classes.integration.ts index 3952228..11d257a 100644 --- a/ts/integrations/apcupsd/apcupsd.classes.integration.ts +++ b/ts/integrations/apcupsd/apcupsd.classes.integration.ts @@ -1,27 +1,84 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { ApcupsdClient } from './apcupsd.classes.client.js'; +import { ApcupsdConfigFlow } from './apcupsd.classes.configflow.js'; +import { createApcupsdDiscoveryDescriptor } from './apcupsd.discovery.js'; +import { ApcupsdMapper } from './apcupsd.mapper.js'; +import type { IApcupsdConfig } from './apcupsd.types.js'; -export class HomeAssistantApcupsdIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "apcupsd", - displayName: "APC UPS Daemon", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/apcupsd", - "upstreamDomain": "apcupsd", - "integrationType": "device", - "iotClass": "local_polling", - "qualityScale": "platinum", - "requirements": [ - "aioapcaccess==1.0.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@yuxincs" - ] -}, - }); +export class ApcupsdIntegration extends BaseIntegration { + public readonly domain = 'apcupsd'; + public readonly displayName = 'APC UPS Daemon'; + public readonly status = 'read-only-runtime' as const; + public readonly discoveryDescriptor = createApcupsdDiscoveryDescriptor(); + public readonly configFlow = new ApcupsdConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/apcupsd', + upstreamDomain: 'apcupsd', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'platinum', + requirements: ['aioapcaccess==1.0.0'], + dependencies: [], + afterDependencies: [], + codeowners: ['@yuxincs'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/apcupsd', + discovery: { + manual: true, + note: 'Home Assistant APCUPSd has no automatic discovery; manual NIS host/port and snapshot inputs are implemented.', + }, + runtime: { + type: 'read-only-runtime', + polling: 'local TCP APCUPSd NIS status command', + services: ['snapshot', 'status', 'refresh'], + controls: false, + }, + }; + + public async setup(configArg: IApcupsdConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new ApcupsdRuntime(new ApcupsdClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantApcupsdIntegration extends ApcupsdIntegration {} + +class ApcupsdRuntime implements IIntegrationRuntime { + public domain = 'apcupsd'; + + constructor(private readonly client: ApcupsdClient) {} + + public async devices(): Promise { + return ApcupsdMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return ApcupsdMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain !== 'apcupsd') { + return { success: false, error: `Unsupported APCUPSd service domain: ${requestArg.domain}` }; + } + if (requestArg.service === 'snapshot' || requestArg.service === 'status') { + return { success: true, data: await this.client.getSnapshot() }; + } + if (requestArg.service === 'refresh') { + return { success: true, data: await this.client.refresh() }; + } + return { success: false, error: `Unsupported APCUPSd 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/apcupsd/apcupsd.discovery.ts b/ts/integrations/apcupsd/apcupsd.discovery.ts new file mode 100644 index 0000000..5becf18 --- /dev/null +++ b/ts/integrations/apcupsd/apcupsd.discovery.ts @@ -0,0 +1,93 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IApcupsdManualEntry, IApcupsdSnapshot } from './apcupsd.types.js'; +import { apcupsdDefaultPort } from './apcupsd.types.js'; + +export class ApcupsdManualMatcher implements IDiscoveryMatcher { + public id = 'apcupsd-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual APCUPSd NIS setup entries.'; + + public async matches(inputArg: IApcupsdManualEntry): Promise { + const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const hasSnapshot = Boolean(inputArg.snapshot || inputArg.rawStatus || inputArg.metadata?.snapshot || inputArg.metadata?.rawStatus); + const matched = Boolean(inputArg.host || inputArg.port === apcupsdDefaultPort || inputArg.metadata?.apcupsd || hasSnapshot || haystack.includes('apcupsd') || haystack.includes('apc ups') || haystack.includes('ups daemon')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain APCUPSd setup hints.' }; + } + + const port = inputArg.port || apcupsdDefaultPort; + const serialNumber = this.snapshotSerial(inputArg.snapshot || inputArg.metadata?.snapshot as IApcupsdSnapshot | undefined); + const id = inputArg.id || serialNumber || (inputArg.host ? `${inputArg.host}:${port}` : undefined); + return { + matched: true, + confidence: inputArg.host || hasSnapshot ? 'high' : 'medium', + reason: 'Manual entry can start APCUPSd setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'apcupsd', + id, + host: inputArg.host, + port, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'APC', + model: inputArg.model || 'UPS', + metadata: { + ...inputArg.metadata, + apcupsd: true, + hasSnapshot, + serialNumber, + }, + }, + }; + } + + private snapshotSerial(snapshotArg: IApcupsdSnapshot | undefined): string | undefined { + return snapshotArg?.ups.serialNumber || snapshotArg?.status.SERIALNO; + } +} + +export class ApcupsdCandidateValidator implements IDiscoveryValidator { + public id = 'apcupsd-candidate-validator'; + public description = 'Validate APCUPSd candidates have manual endpoint or snapshot information.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === 'apcupsd' + || candidateArg.port === apcupsdDefaultPort + || Boolean(candidateArg.metadata?.apcupsd) + || haystack.includes('apcupsd') + || haystack.includes('apc ups') + || haystack.includes('ups daemon'); + const hasUsableSource = Boolean(candidateArg.host || candidateArg.metadata?.hasSnapshot || candidateArg.metadata?.rawStatus || candidateArg.metadata?.snapshot); + + if (!matched || !hasUsableSource) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'APCUPSd candidate lacks host or snapshot information.' : 'Candidate is not APCUPSd.', + }; + } + + const port = candidateArg.port || apcupsdDefaultPort; + const serialNumber = typeof candidateArg.metadata?.serialNumber === 'string' ? candidateArg.metadata.serialNumber : undefined; + const normalizedDeviceId = candidateArg.id || serialNumber || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined); + return { + matched: true, + confidence: candidateArg.id || serialNumber ? 'certain' : 'high', + reason: 'Candidate has APCUPSd metadata and a usable manual source.', + normalizedDeviceId, + candidate: { + ...candidateArg, + port, + }, + }; + } +} + +export const createApcupsdDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'apcupsd', displayName: 'APC UPS Daemon' }) + .addMatcher(new ApcupsdManualMatcher()) + .addValidator(new ApcupsdCandidateValidator()); +}; diff --git a/ts/integrations/apcupsd/apcupsd.mapper.ts b/ts/integrations/apcupsd/apcupsd.mapper.ts new file mode 100644 index 0000000..c9cb722 --- /dev/null +++ b/ts/integrations/apcupsd/apcupsd.mapper.ts @@ -0,0 +1,152 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IApcupsdSnapshot } from './apcupsd.types.js'; + +const apcupsdDomain = 'apcupsd'; + +export class ApcupsdMapper { + public static toDevices(snapshotArg: IApcupsdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return [{ + id: this.upsDeviceId(snapshotArg), + integrationDomain: apcupsdDomain, + name: this.upsName(snapshotArg), + protocol: 'unknown', + manufacturer: 'APC', + model: snapshotArg.ups.model || 'UPS', + online: snapshotArg.online, + features: [ + { id: 'status', capability: 'sensor', name: 'Status', readable: true, writable: false }, + { id: 'line_online', capability: 'sensor', name: 'Online status', readable: true, writable: false }, + { id: 'battery_charge', capability: 'sensor', name: 'Battery charge', readable: true, writable: false, unit: '%' }, + { id: 'battery_runtime', capability: 'sensor', name: 'Battery runtime', readable: true, writable: false, unit: 'min' }, + { id: 'load', capability: 'sensor', name: 'Load', readable: true, writable: false, unit: '%' }, + { id: 'line_voltage', capability: 'sensor', name: 'Input voltage', readable: true, writable: false, unit: 'V' }, + { id: 'output_voltage', capability: 'sensor', name: 'Output voltage', readable: true, writable: false, unit: 'V' }, + { id: 'nominal_power', capability: 'sensor', name: 'Nominal output power', readable: true, writable: false, unit: 'W' }, + ], + state: [ + { featureId: 'status', value: snapshotArg.ups.status || null, updatedAt }, + { featureId: 'line_online', value: snapshotArg.ups.lineOnline ?? null, updatedAt }, + { featureId: 'battery_charge', value: snapshotArg.battery.chargePercent ?? null, updatedAt }, + { featureId: 'battery_runtime', value: snapshotArg.battery.timeLeftMinutes ?? null, updatedAt }, + { featureId: 'load', value: snapshotArg.power.loadPercent ?? null, updatedAt }, + { featureId: 'line_voltage', value: snapshotArg.power.lineVoltage ?? null, updatedAt }, + { featureId: 'output_voltage', value: snapshotArg.power.outputVoltage ?? null, updatedAt }, + { featureId: 'nominal_power', value: snapshotArg.power.nominalPowerWatts ?? null, updatedAt }, + ], + metadata: this.cleanAttributes({ + host: snapshotArg.ups.host, + port: snapshotArg.ups.port, + serialNumber: snapshotArg.ups.serialNumber, + firmware: snapshotArg.ups.firmware, + version: snapshotArg.ups.version, + hostname: snapshotArg.ups.hostname, + mode: snapshotArg.ups.mode, + source: snapshotArg.source, + statusKeys: Object.keys(snapshotArg.status).sort(), + error: snapshotArg.error, + }), + }]; + } + + public static toEntities(snapshotArg: IApcupsdSnapshot): IIntegrationEntity[] { + const deviceId = this.upsDeviceId(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + const baseName = this.upsName(snapshotArg); + const available = snapshotArg.online; + const entities: IIntegrationEntity[] = []; + + entities.push(this.entity(snapshotArg, 'sensor', 'status', `${baseName} Status`, snapshotArg.ups.status || 'unknown', { + deviceClass: 'enum', + statusFlag: snapshotArg.ups.statusFlag, + mode: snapshotArg.ups.mode, + lastTransfer: snapshotArg.power.lastTransfer, + selfTest: snapshotArg.battery.selfTest, + lastSelfTest: snapshotArg.battery.lastSelfTest, + statusKeys: Object.keys(snapshotArg.status).sort(), + }, deviceId, uniqueBase, available)); + + if (snapshotArg.ups.lineOnline !== undefined) { + entities.push(this.entity(snapshotArg, 'binary_sensor', 'online_status', `${baseName} Online Status`, this.binaryState(snapshotArg.ups.lineOnline), { + statusFlag: snapshotArg.ups.statusFlag, + deviceClass: 'power', + }, deviceId, uniqueBase, available)); + } + + this.pushNumberEntity(entities, snapshotArg, 'battery_charge', `${baseName} Battery Charge`, snapshotArg.battery.chargePercent, '%', deviceId, uniqueBase, available, { deviceClass: 'battery', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'battery_voltage', `${baseName} Battery Voltage`, snapshotArg.battery.voltage, 'V', deviceId, uniqueBase, available, { deviceClass: 'voltage', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'battery_nominal_voltage', `${baseName} Battery Nominal Voltage`, snapshotArg.battery.nominalVoltage, 'V', deviceId, uniqueBase, available, { deviceClass: 'voltage', entityCategory: 'diagnostic' }); + this.pushNumberEntity(entities, snapshotArg, 'time_left', `${baseName} Time Left`, snapshotArg.battery.timeLeftMinutes, 'min', deviceId, uniqueBase, available, { deviceClass: 'duration', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'time_on_battery', `${baseName} Time On Battery`, snapshotArg.battery.timeOnBatterySeconds, 's', deviceId, uniqueBase, available, { deviceClass: 'duration', stateClass: 'total_increasing' }); + this.pushNumberEntity(entities, snapshotArg, 'total_time_on_battery', `${baseName} Total Time On Battery`, snapshotArg.battery.totalTimeOnBatterySeconds, 's', deviceId, uniqueBase, available, { deviceClass: 'duration', stateClass: 'total_increasing' }); + this.pushStringEntity(entities, snapshotArg, 'battery_status', `${baseName} Battery Status`, snapshotArg.battery.status, deviceId, uniqueBase, available); + + this.pushNumberEntity(entities, snapshotArg, 'load', `${baseName} Load`, snapshotArg.power.loadPercent, '%', deviceId, uniqueBase, available, { stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'input_voltage', `${baseName} Input Voltage`, snapshotArg.power.lineVoltage, 'V', deviceId, uniqueBase, available, { deviceClass: 'voltage', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'output_voltage', `${baseName} Output Voltage`, snapshotArg.power.outputVoltage, 'V', deviceId, uniqueBase, available, { deviceClass: 'voltage', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'line_frequency', `${baseName} Line Frequency`, snapshotArg.power.lineFrequency, 'Hz', deviceId, uniqueBase, available, { deviceClass: 'frequency', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'nominal_output_power', `${baseName} Nominal Output Power`, snapshotArg.power.nominalPowerWatts, 'W', deviceId, uniqueBase, available, { deviceClass: 'power', entityCategory: 'diagnostic' }); + this.pushNumberEntity(entities, snapshotArg, 'nominal_apparent_power', `${baseName} Nominal Apparent Power`, snapshotArg.power.nominalApparentPowerVa, 'VA', deviceId, uniqueBase, available, { deviceClass: 'apparent_power', entityCategory: 'diagnostic' }); + this.pushNumberEntity(entities, snapshotArg, 'output_current', `${baseName} Output Current`, snapshotArg.power.outputCurrentAmps, 'A', deviceId, uniqueBase, available, { deviceClass: 'current', stateClass: 'measurement' }); + this.pushNumberEntity(entities, snapshotArg, 'transfer_count', `${baseName} Transfer Count`, snapshotArg.power.transferCount, undefined, deviceId, uniqueBase, available, { stateClass: 'total_increasing' }); + + return entities; + } + + public static upsDeviceId(snapshotArg: IApcupsdSnapshot): string { + return `apcupsd.ups.${this.uniqueBase(snapshotArg)}`; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'apcupsd'; + } + + private static pushNumberEntity(entitiesArg: IIntegrationEntity[], snapshotArg: IApcupsdSnapshot, keyArg: string, nameArg: string, valueArg: number | undefined, unitArg: string | undefined, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean, attributesArg: Record = {}): void { + if (valueArg === undefined) { + return; + } + entitiesArg.push(this.entity(snapshotArg, 'sensor', keyArg, nameArg, valueArg, { + unit: unitArg, + ...attributesArg, + }, deviceIdArg, uniqueBaseArg, availableArg)); + } + + private static pushStringEntity(entitiesArg: IIntegrationEntity[], snapshotArg: IApcupsdSnapshot, keyArg: string, nameArg: string, valueArg: string | undefined, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean, attributesArg: Record = {}): void { + if (!valueArg) { + return; + } + entitiesArg.push(this.entity(snapshotArg, 'sensor', keyArg, nameArg, valueArg, attributesArg, deviceIdArg, uniqueBaseArg, availableArg)); + } + + private static entity(snapshotArg: IApcupsdSnapshot, platformArg: IIntegrationEntity['platform'], keyArg: string, nameArg: string, stateArg: unknown, attributesArg: Record, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean): IIntegrationEntity { + void snapshotArg; + return { + id: `${platformArg}.${this.slug(nameArg)}`, + uniqueId: `apcupsd_${uniqueBaseArg}_${keyArg}`, + integrationDomain: apcupsdDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static upsName(snapshotArg: IApcupsdSnapshot): string { + return snapshotArg.ups.name || snapshotArg.ups.hostname || snapshotArg.ups.model || 'APC UPS'; + } + + private static uniqueBase(snapshotArg: IApcupsdSnapshot): string { + return this.slug(snapshotArg.ups.serialNumber || snapshotArg.ups.id || snapshotArg.ups.host || this.upsName(snapshotArg)); + } + + private static binaryState(valueArg: boolean | undefined): 'on' | 'off' | 'unknown' { + return valueArg === undefined ? 'unknown' : valueArg ? 'on' : 'off'; + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } +} diff --git a/ts/integrations/apcupsd/apcupsd.types.ts b/ts/integrations/apcupsd/apcupsd.types.ts index 31114d0..8e0f1d7 100644 --- a/ts/integrations/apcupsd/apcupsd.types.ts +++ b/ts/integrations/apcupsd/apcupsd.types.ts @@ -1,4 +1,105 @@ -export interface IHomeAssistantApcupsdConfig { - // TODO: replace with the TypeScript-native config for apcupsd. - [key: string]: unknown; +export const apcupsdDefaultPort = 3551; +export const apcupsdDefaultTimeoutMs = 10000; + +export type TApcupsdTransport = 'tcp' | 'snapshot'; +export type TApcupsdSnapshotSource = 'tcp' | 'manual' | 'snapshot' | 'runtime'; + +export interface IApcupsdConfig { + host?: string; + port?: number; + timeoutMs?: number; + name?: string; + uniqueId?: string; + transport?: TApcupsdTransport; + snapshot?: IApcupsdSnapshot; + rawStatus?: string | IApcupsdStatusRecord; + online?: boolean; +} + +export interface IHomeAssistantApcupsdConfig extends IApcupsdConfig {} + +export interface IApcupsdStatusRecord { + [key: string]: string; +} + +export interface IApcupsdUpsInfo { + id?: string; + name?: string; + host?: string; + port?: number; + model?: string; + serialNumber?: string; + firmware?: string; + version?: string; + hostname?: string; + mode?: string; + status?: string; + statusFlag?: number; + lineOnline?: boolean; +} + +export interface IApcupsdBatteryInfo { + chargePercent?: number; + voltage?: number; + nominalVoltage?: number; + timeLeftMinutes?: number; + timeOnBatterySeconds?: number; + totalTimeOnBatterySeconds?: number; + status?: string; + replacementDate?: string; + lastSelfTest?: string; + selfTest?: string; + badBatteries?: number; + externalBatteries?: number; +} + +export interface IApcupsdPowerInfo { + lineVoltage?: number; + outputVoltage?: number; + loadPercent?: number; + loadApparentPercent?: number; + lineFrequency?: number; + nominalInputVoltage?: number; + nominalOutputVoltage?: number; + nominalPowerWatts?: number; + nominalApparentPowerVa?: number; + outputCurrentAmps?: number; + inputVoltageHigh?: number; + inputVoltageLow?: number; + transferHighVoltage?: number; + transferLowVoltage?: number; + transferCount?: number; + lastTransfer?: string; +} + +export interface IApcupsdSnapshot { + ups: IApcupsdUpsInfo; + battery: IApcupsdBatteryInfo; + power: IApcupsdPowerInfo; + status: IApcupsdStatusRecord; + online: boolean; + updatedAt?: string; + source?: TApcupsdSnapshotSource; + raw?: string; + error?: string; +} + +export interface IApcupsdManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + snapshot?: IApcupsdSnapshot; + rawStatus?: string | IApcupsdStatusRecord; + metadata?: Record; +} + +export interface IApcupsdDiscoveryRecord { + source: 'manual'; + host?: string; + port?: number; + name?: string; + id?: string; } diff --git a/ts/integrations/apcupsd/index.ts b/ts/integrations/apcupsd/index.ts index b02bc9e..e9d25b9 100644 --- a/ts/integrations/apcupsd/index.ts +++ b/ts/integrations/apcupsd/index.ts @@ -1,2 +1,6 @@ export * from './apcupsd.classes.integration.js'; +export * from './apcupsd.classes.client.js'; +export * from './apcupsd.classes.configflow.js'; +export * from './apcupsd.discovery.js'; +export * from './apcupsd.mapper.js'; export * from './apcupsd.types.js'; diff --git a/ts/integrations/blebox/.generated-by-smarthome-exchange b/ts/integrations/blebox/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/blebox/.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/blebox/blebox.classes.client.ts b/ts/integrations/blebox/blebox.classes.client.ts new file mode 100644 index 0000000..fe784b8 --- /dev/null +++ b/ts/integrations/blebox/blebox.classes.client.ts @@ -0,0 +1,385 @@ +import type { + IBleboxConfig, + IBleboxCoverCommandRequest, + IBleboxDeviceInfo, + IBleboxLightStatePatch, + IBleboxProductProfile, + IBleboxSnapshot, + TBleboxJsonValue, +} from './blebox.types.js'; + +interface IBleboxProfileTemplate { + minApiLevel: number; + apiPath: string; + extendedStatePath?: string; + supports: IBleboxProductProfile['supports']; +} + +const defaultPort = 80; +const defaultSetupTimeoutMs = 10000; +const defaultApiLevel = 20151206; + +const profileTemplates: Record = { + airSensor: [ + { minApiLevel: 20180403, apiPath: '/api/air/state', supports: { sensor: true } }, + ], + dimmerBox: [ + { minApiLevel: 20151206, apiPath: '/api/dimmer/state', supports: { light: true } }, + { minApiLevel: 20170829, apiPath: '/api/dimmer/state', supports: { light: true } }, + ], + gateBox: [ + { minApiLevel: 20151206, apiPath: '/api/gate/state', supports: { cover: true } }, + { minApiLevel: 20200831, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { cover: true } }, + ], + gateController: [ + { minApiLevel: 20180604, apiPath: '/api/gatecontroller/state', extendedStatePath: '/api/gatecontroller/extended/state', supports: { cover: true } }, + ], + multiSensor: [ + { minApiLevel: 20200831, apiPath: '/state', extendedStatePath: '/state/extended', supports: { sensor: true, binary_sensor: true } }, + { minApiLevel: 20210413, apiPath: '/state', extendedStatePath: '/state/extended', supports: { sensor: true, binary_sensor: true } }, + { minApiLevel: 20220114, apiPath: '/state', extendedStatePath: '/state/extended', supports: { sensor: true, binary_sensor: true } }, + { minApiLevel: 20230606, apiPath: '/state', extendedStatePath: '/state/extended', supports: { sensor: true, binary_sensor: true } }, + ], + shutterBox: [ + { minApiLevel: 20180604, apiPath: '/api/shutter/state', extendedStatePath: '/api/shutter/extended/state', supports: { cover: true } }, + ], + switchBox: [ + { minApiLevel: 20180604, apiPath: '/api/relay/state', supports: { switch: true } }, + { minApiLevel: 20190808, apiPath: '/api/relay/extended/state', extendedStatePath: '/api/relay/extended/state', supports: { switch: true, sensor: true } }, + { minApiLevel: 20200229, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { switch: true, sensor: true } }, + { minApiLevel: 20200831, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { switch: true, sensor: true } }, + { minApiLevel: 20220114, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { switch: true, sensor: true } }, + ], + switchBoxD: [ + { minApiLevel: 20190808, apiPath: '/api/relay/extended/state', extendedStatePath: '/api/relay/extended/state', supports: { switch: true, sensor: true } }, + { minApiLevel: 20200229, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { switch: true, sensor: true } }, + { minApiLevel: 20200831, apiPath: '/state/extended', extendedStatePath: '/state/extended', supports: { switch: true, sensor: true } }, + ], + tempSensor: [ + { minApiLevel: 20180604, apiPath: '/api/tempsensor/state', supports: { sensor: true } }, + ], + wLightBox: [ + { minApiLevel: 20151206, apiPath: '/api/device/state', supports: { light: true } }, + { minApiLevel: 20190808, apiPath: '/api/rgbw/state', extendedStatePath: '/api/rgbw/extended/state', supports: { light: true } }, + { minApiLevel: 20200229, apiPath: '/api/rgbw/state', extendedStatePath: '/api/rgbw/extended/state', supports: { light: true } }, + ], + wLightBoxS: [ + { minApiLevel: 20151206, apiPath: '/api/device/state', supports: { light: true } }, + { minApiLevel: 20180718, apiPath: '/api/light/state', supports: { light: true } }, + { minApiLevel: 20200229, apiPath: '/api/rgbw/state', extendedStatePath: '/api/rgbw/extended/state', supports: { light: true } }, + ], +}; + +export class BleboxClient { + constructor(private readonly config: IBleboxConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.config.snapshot; + } + return this.identifyAndReadState(); + } + + public async identifyAndReadState(): Promise { + const rawInfo = await this.fetchInitialInfo(); + const device = this.extractDeviceInfo(rawInfo); + const profile = resolveBleboxProductProfile(device); + + let state = rawInfo; + if (profile.apiPath !== '/api/device/state') { + state = await this.requestJson(profile.apiPath); + } + + let extendedState: TBleboxJsonValue | undefined; + if (profile.extendedStatePath) { + try { + extendedState = await this.requestJson(profile.extendedStatePath); + } catch { + extendedState = undefined; + } + } + + return { + device, + state, + extendedState, + rawInfo, + host: this.config.host, + port: this.config.port || defaultPort, + profile, + }; + } + + public async setSwitchState(unitIdArg: number, onArg: boolean, snapshotArg?: IBleboxSnapshot): Promise { + const snapshot = snapshotArg ?? await this.getSnapshot(); + const productType = normalizedProductType(snapshot.device); + const apiLevel = numericApiLevel(snapshot.device); + let path: string; + + if (productType === 'switchBoxD' || productType === 'switchBox' && apiLevel >= 20220114) { + path = `/s/${unitIdArg}/${onArg ? 1 : 0}`; + } else { + path = `/s/${onArg ? 1 : 0}`; + } + + await this.commandGet(path); + } + + public async setLightState(patchArg: IBleboxLightStatePatch, snapshotArg?: IBleboxSnapshot): Promise { + const snapshot = snapshotArg ?? await this.getSnapshot(); + const productType = normalizedProductType(snapshot.device); + + if (productType === 'dimmerBox') { + const brightness = patchArg.on ? patchArg.brightness ?? 255 : 0; + this.assertByte(brightness, 'brightness'); + await this.requestJson('/api/dimmer/set', { + method: 'POST', + body: { dimmer: { desiredBrightness: brightness } }, + }); + return; + } + + const desiredColor = patchArg.on ? this.desiredLightColor(snapshot, patchArg) : this.offLightColor(snapshot); + const usesLegacyLightPath = productType === 'wLightBoxS' && numericApiLevel(snapshot.device) < 20200229; + await this.requestJson(usesLegacyLightPath ? '/api/light/set' : '/api/rgbw/set', { + method: 'POST', + body: usesLegacyLightPath ? { light: { desiredColor } } : { rgbw: { desiredColor } }, + }); + + if (patchArg.on && patchArg.effect !== undefined) { + const effectIndex = this.effectIndex(snapshot, patchArg.effect); + await this.commandGet(`/s/x/${effectIndex}`); + } + } + + public async runCoverCommand(commandArg: IBleboxCoverCommandRequest, snapshotArg?: IBleboxSnapshot): Promise { + const snapshot = snapshotArg ?? await this.getSnapshot(); + const productType = normalizedProductType(snapshot.device); + const path = this.coverCommandPath(productType, commandArg); + await this.commandGet(path); + } + + public async destroy(): Promise {} + + private async fetchInitialInfo(): Promise { + try { + return await this.requestJson('/api/device/state'); + } catch { + return this.requestJson('/info'); + } + } + + private async commandGet(pathArg: string): Promise { + await this.requestJson(pathArg); + } + + private async requestJson(pathArg: string, optionsArg: { method?: 'GET' | 'POST'; body?: unknown } = {}): Promise { + if (!this.config.host) { + throw new Error('BleBox host is required when fixture snapshot data is not provided.'); + } + + const method = optionsArg.method || 'GET'; + const headers: Record = {}; + let body: string | undefined; + if (optionsArg.body !== undefined) { + headers['content-type'] = 'application/json'; + body = typeof optionsArg.body === 'string' ? optionsArg.body : JSON.stringify(optionsArg.body); + } + if (this.config.username && this.config.password) { + headers.authorization = `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`; + } + + const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`, { + method, + headers, + body, + signal: AbortSignal.timeout(this.config.timeoutMs || defaultSetupTimeoutMs), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`BleBox request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + return response.json() as Promise; + } + + private extractDeviceInfo(valueArg: TBleboxJsonValue): IBleboxDeviceInfo { + const root = asRecord(valueArg); + if (!root) { + throw new Error('BleBox device response is not a JSON object.'); + } + const device = asRecord(root.device) || root; + return device as unknown as IBleboxDeviceInfo; + } + + private desiredLightColor(snapshotArg: IBleboxSnapshot, patchArg: IBleboxLightStatePatch): string { + if (patchArg.rgbwwColor) { + patchArg.rgbwwColor.forEach((valueArg, indexArg) => this.assertByte(valueArg, `rgbwwColor[${indexArg}]`)); + return patchArg.rgbwwColor.map((valueArg) => valueArg.toString(16).padStart(2, '0')).join(''); + } + if (patchArg.rgbwColor) { + patchArg.rgbwColor.forEach((valueArg, indexArg) => this.assertByte(valueArg, `rgbwColor[${indexArg}]`)); + return patchArg.rgbwColor.map((valueArg) => valueArg.toString(16).padStart(2, '0')).join(''); + } + if (patchArg.rgbColor) { + patchArg.rgbColor.forEach((valueArg, indexArg) => this.assertByte(valueArg, `rgbColor[${indexArg}]`)); + const current = this.currentDesiredColor(snapshotArg); + const white = current.length >= 8 ? current.slice(6, 8) : ''; + return `${patchArg.rgbColor.map((valueArg) => valueArg.toString(16).padStart(2, '0')).join('')}${white}`; + } + if (patchArg.colorTempKelvin !== undefined) { + const brightness = patchArg.brightness ?? this.lightBrightness(snapshotArg) ?? 255; + this.assertByte(brightness, 'brightness'); + const mired = Math.round(1000000 / patchArg.colorTempKelvin); + const normalized = Math.max(0, Math.min(255, Math.round((mired - 154) * 255 / (370 - 154)))); + const cold = normalized < 128 ? 255 : Math.max(0, Math.min(255, (255 - normalized) * 2)); + const warm = normalized < 128 ? Math.min(255, normalized * 2) : 255; + return [warm, cold].map((valueArg) => Math.round(valueArg * brightness / 255).toString(16).padStart(2, '0')).join(''); + } + if (patchArg.brightness !== undefined) { + this.assertByte(patchArg.brightness, 'brightness'); + const current = this.currentDesiredColor(snapshotArg); + if (current.length <= 2) { + return patchArg.brightness.toString(16).padStart(2, '0'); + } + const channels = current.match(/../g)?.map((valueArg) => Number.parseInt(valueArg, 16)) || [255, 255, 255, 255]; + const max = Math.max(...channels, 1); + return channels.map((valueArg) => Math.round(valueArg * patchArg.brightness! / max).toString(16).padStart(2, '0')).join(''); + } + + const lastOn = this.currentLastOnColor(snapshotArg); + if (lastOn && Number.parseInt(lastOn, 16) > 0) { + return lastOn; + } + const current = this.currentDesiredColor(snapshotArg); + return 'f'.repeat(Math.max(current.length, normalizedProductType(snapshotArg.device) === 'wLightBoxS' ? 2 : 8)); + } + + private offLightColor(snapshotArg: IBleboxSnapshot): string { + return '0'.repeat(Math.max(this.currentDesiredColor(snapshotArg).length, normalizedProductType(snapshotArg.device) === 'wLightBoxS' ? 2 : 8)); + } + + private currentDesiredColor(snapshotArg: IBleboxSnapshot): string { + const colorState = this.lightStateRecord(snapshotArg); + const desired = colorState?.desiredColor; + return typeof desired === 'string' && desired.length > 0 ? desired.replace(/-/g, '0') : normalizedProductType(snapshotArg.device) === 'wLightBoxS' ? 'ff' : 'ffffffff'; + } + + private currentLastOnColor(snapshotArg: IBleboxSnapshot): string | undefined { + const lastOn = this.lightStateRecord(snapshotArg)?.lastOnColor; + return typeof lastOn === 'string' ? lastOn.replace(/-/g, '0') : undefined; + } + + private lightBrightness(snapshotArg: IBleboxSnapshot): number | undefined { + const desired = this.currentDesiredColor(snapshotArg); + const channels = desired.match(/../g)?.map((valueArg) => Number.parseInt(valueArg, 16)) || []; + return channels.length ? Math.max(...channels) : undefined; + } + + private lightStateRecord(snapshotArg: IBleboxSnapshot): Record | undefined { + const extended = asRecord(snapshotArg.extendedState); + const state = asRecord(snapshotArg.state); + return asRecord(extended?.rgbw) || asRecord(state?.rgbw) || asRecord(state?.light) || asRecord(extended?.light); + } + + private effectIndex(snapshotArg: IBleboxSnapshot, effectArg: string | number): number { + if (typeof effectArg === 'number') { + if (!Number.isInteger(effectArg) || effectArg < 0) { + throw new Error('BleBox light effect must be a non-negative integer.'); + } + return effectArg; + } + const effectNames = asRecord(this.lightStateRecord(snapshotArg)?.effectsNames); + if (!effectNames) { + throw new Error('BleBox light effects are not available for this snapshot.'); + } + for (const [key, value] of Object.entries(effectNames)) { + if (value === effectArg) { + return Number(key); + } + } + throw new Error(`BleBox light effect is not supported: ${effectArg}`); + } + + private coverCommandPath(productTypeArg: string, commandArg: IBleboxCoverCommandRequest): string { + if (commandArg.command === 'set_cover_position') { + this.assertPercent(commandArg.position, 'position'); + } + if (commandArg.command === 'set_cover_tilt_position') { + this.assertPercent(commandArg.tiltPosition, 'tiltPosition'); + } + + if (productTypeArg === 'shutterBox') { + if (commandArg.command === 'open_cover') return '/s/u'; + if (commandArg.command === 'close_cover') return '/s/d'; + if (commandArg.command === 'stop_cover') return '/s/s'; + if (commandArg.command === 'set_cover_position') return `/s/p/${100 - commandArg.position!}`; + if (commandArg.command === 'open_cover_tilt') return '/s/t/0'; + if (commandArg.command === 'close_cover_tilt') return '/s/t/100'; + if (commandArg.command === 'set_cover_tilt_position') return `/s/t/${100 - commandArg.tiltPosition!}`; + } + + if (productTypeArg === 'gateController') { + if (commandArg.command === 'open_cover') return '/s/o'; + if (commandArg.command === 'close_cover') return '/s/c'; + if (commandArg.command === 'stop_cover') return '/s/s'; + if (commandArg.command === 'set_cover_position') return `/s/p/${100 - commandArg.position!}`; + } + + if (productTypeArg === 'gateBox') { + if (commandArg.command === 'open_cover' || commandArg.command === 'close_cover') return '/s/p'; + if (commandArg.command === 'stop_cover') return '/s/s'; + } + + throw new Error(`Unsupported BleBox cover command for ${productTypeArg}: ${commandArg.command}`); + } + + private assertByte(valueArg: number, nameArg: string): void { + if (!Number.isInteger(valueArg) || valueArg < 0 || valueArg > 255) { + throw new Error(`BleBox ${nameArg} must be an integer between 0 and 255.`); + } + } + + private assertPercent(valueArg: number | undefined, nameArg: string): void { + if (!Number.isInteger(valueArg) || valueArg! < 0 || valueArg! > 100) { + throw new Error(`BleBox ${nameArg} must be an integer between 0 and 100.`); + } + } + + private baseUrl(): string { + const port = this.config.port && this.config.port !== defaultPort ? `:${this.config.port}` : ''; + return `${this.config.protocol || 'http'}://${this.config.host}${port}`; + } +} + +export const resolveBleboxProductProfile = (deviceArg: IBleboxDeviceInfo): IBleboxProductProfile => { + const productType = normalizedProductType(deviceArg); + const apiLevel = numericApiLevel(deviceArg); + const templates = profileTemplates[productType] || []; + const template = [...templates].sort((leftArg, rightArg) => rightArg.minApiLevel - leftArg.minApiLevel) + .find((entryArg) => apiLevel >= entryArg.minApiLevel); + return { + productType, + model: productType, + apiLevel, + apiPath: template?.apiPath || '/api/device/state', + extendedStatePath: template?.extendedStatePath, + supports: template?.supports || {}, + }; +}; + +export const normalizedProductType = (deviceArg: IBleboxDeviceInfo): string => { + const type = String(deviceArg.type || deviceArg.product || 'blebox'); + const product = String(deviceArg.product || type); + return type === 'wLightBox' && product === 'wLightBoxS' ? 'wLightBoxS' : type; +}; + +export const numericApiLevel = (deviceArg: IBleboxDeviceInfo): number => { + const value = Number(deviceArg.apiLevel ?? defaultApiLevel); + return Number.isFinite(value) ? value : defaultApiLevel; +}; + +const asRecord = (valueArg: unknown): Record | undefined => { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) + ? valueArg as Record + : undefined; +}; diff --git a/ts/integrations/blebox/blebox.classes.configflow.ts b/ts/integrations/blebox/blebox.classes.configflow.ts new file mode 100644 index 0000000..d8cf76d --- /dev/null +++ b/ts/integrations/blebox/blebox.classes.configflow.ts @@ -0,0 +1,60 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IBleboxConfig, IBleboxSnapshot } from './blebox.types.js'; + +const defaultHost = '192.168.0.2'; +const defaultPort = 80; + +export class BleboxConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Set up BleBox device', + description: 'Connect to a local BleBox device over HTTP. Username and password are only required when the device has HTTP basic authentication enabled.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number', required: true }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + ], + submit: async (valuesArg) => { + const host = String(valuesArg.host || candidateArg.host || defaultHost).trim(); + const port = Number(valuesArg.port || candidateArg.port || defaultPort); + if (!host || host.includes('://')) { + return { kind: 'error', title: 'Invalid BleBox host', error: 'BleBox host must be a hostname or IP address without a URL scheme.' }; + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { kind: 'error', title: 'Invalid BleBox port', error: 'BleBox port must be an integer between 1 and 65535.' }; + } + + const username = optionalString(valuesArg.username); + const password = optionalString(valuesArg.password); + if (Boolean(username) !== Boolean(password)) { + return { kind: 'error', title: 'Incomplete BleBox credentials', error: 'BleBox username and password must be provided together.' }; + } + + return { + kind: 'done', + title: 'BleBox device configured', + config: { + host, + port, + username, + password, + protocol: 'http', + snapshot: isBleboxSnapshot(candidateArg.metadata?.snapshot) ? candidateArg.metadata?.snapshot : undefined, + }, + }; + }, + }; + } +} + +const optionalString = (valueArg: unknown): string | undefined => { + const value = String(valueArg || '').trim(); + return value || undefined; +}; + +const isBleboxSnapshot = (valueArg: unknown): valueArg is IBleboxSnapshot => { + return typeof valueArg === 'object' && valueArg !== null && 'device' in valueArg; +}; diff --git a/ts/integrations/blebox/blebox.classes.integration.ts b/ts/integrations/blebox/blebox.classes.integration.ts index b109295..2aee920 100644 --- a/ts/integrations/blebox/blebox.classes.integration.ts +++ b/ts/integrations/blebox/blebox.classes.integration.ts @@ -1,28 +1,185 @@ -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 { BleboxClient } from './blebox.classes.client.js'; +import { BleboxConfigFlow } from './blebox.classes.configflow.js'; +import { createBleboxDiscoveryDescriptor } from './blebox.discovery.js'; +import { BleboxMapper } from './blebox.mapper.js'; +import type { IBleboxConfig, IBleboxCoverFeature, IBleboxLightFeature, IBleboxLightStatePatch, IBleboxSnapshot, IBleboxSwitchFeature, TBleboxCoverCommand, TBleboxFeature } from './blebox.types.js'; -export class HomeAssistantBleboxIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "blebox", - displayName: "BleBox devices", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/blebox", - "upstreamDomain": "blebox", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "blebox-uniapi==2.5.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@bbx-a", - "@swistakm", - "@bkobus-bbx" - ] -}, - }); +export class BleboxIntegration extends BaseIntegration { + public readonly domain = 'blebox'; + public readonly displayName = 'BleBox devices'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createBleboxDiscoveryDescriptor(); + public readonly configFlow = new BleboxConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/blebox', + upstreamDomain: 'blebox', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['blebox-uniapi==2.5.2'], + codeowners: ['@bbx-a', '@swistakm', '@bkobus-bbx'], + }; + + public async setup(configArg: IBleboxConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new BleboxRuntime(new BleboxClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantBleboxIntegration extends BleboxIntegration {} + +class BleboxRuntime implements IIntegrationRuntime { + public domain = 'blebox'; + + constructor(private readonly client: BleboxClient) {} + + public async devices(): Promise { + return BleboxMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return BleboxMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + if (requestArg.domain === 'switch') { + return this.callSwitchService(snapshot, requestArg); + } + if (requestArg.domain === 'light') { + return this.callLightService(snapshot, requestArg); + } + if (requestArg.domain === 'cover') { + return this.callCoverService(snapshot, requestArg); + } + return { success: false, error: `Unsupported BleBox service domain: ${requestArg.domain}` }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callSwitchService(snapshotArg: IBleboxSnapshot, requestArg: IServiceCallRequest): Promise { + if (!['turn_on', 'turn_off'].includes(requestArg.service)) { + return { success: false, error: `Unsupported BleBox switch service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'switch') as IBleboxSwitchFeature | undefined; + if (!feature) { + return { success: false, error: 'BleBox switch service calls require a switch entity target, or an unambiguous switch device target.' }; + } + await this.client.setSwitchState(feature.unitId, requestArg.service === 'turn_on', snapshotArg); + return { success: true }; + } + + private async callLightService(snapshotArg: IBleboxSnapshot, requestArg: IServiceCallRequest): Promise { + if (!['turn_on', 'turn_off'].includes(requestArg.service)) { + return { success: false, error: `Unsupported BleBox light service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'light') as IBleboxLightFeature | undefined; + if (!feature) { + return { success: false, error: 'BleBox light service calls require a light entity target, or an unambiguous light device target.' }; + } + + const patch = this.lightPatch(requestArg, requestArg.service === 'turn_on'); + if ('error' in patch) { + return { success: false, error: patch.error }; + } + await this.client.setLightState(patch, snapshotArg); + return { success: true }; + } + + private async callCoverService(snapshotArg: IBleboxSnapshot, requestArg: IServiceCallRequest): Promise { + const supported = ['open_cover', 'close_cover', 'stop_cover', 'set_cover_position', 'open_cover_tilt', 'close_cover_tilt', 'set_cover_tilt_position']; + if (!supported.includes(requestArg.service)) { + return { success: false, error: `Unsupported BleBox cover service: ${requestArg.service}` }; + } + const feature = this.resolveFeature(snapshotArg, requestArg, 'cover') as IBleboxCoverFeature | undefined; + if (!feature) { + return { success: false, error: 'BleBox cover service calls require a cover entity target, or an unambiguous cover device target.' }; + } + + const position = requestArg.service === 'set_cover_position' ? requestArg.data?.position : undefined; + const tiltPosition = requestArg.service === 'set_cover_tilt_position' ? requestArg.data?.tilt_position : undefined; + if (requestArg.service === 'set_cover_position' && !this.isPercent(position)) { + return { success: false, error: 'BleBox set_cover_position requires data.position as an integer between 0 and 100.' }; + } + if (requestArg.service === 'set_cover_tilt_position' && !this.isPercent(tiltPosition)) { + return { success: false, error: 'BleBox set_cover_tilt_position requires data.tilt_position as an integer between 0 and 100.' }; + } + + await this.client.runCoverCommand({ + command: requestArg.service as TBleboxCoverCommand, + position: typeof position === 'number' ? position : undefined, + tiltPosition: typeof tiltPosition === 'number' ? tiltPosition : undefined, + }, snapshotArg); + return { success: true }; + } + + private resolveFeature(snapshotArg: IBleboxSnapshot, requestArg: IServiceCallRequest, platformArg: TBleboxFeature['platform']): TBleboxFeature | undefined { + const features = BleboxMapper.features(snapshotArg).filter((featureArg) => featureArg.platform === platformArg); + if (requestArg.target.entityId) { + const entities = BleboxMapper.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 = requestArg.target.deviceId === BleboxMapper.deviceId(snapshotArg) ? features : []; + return deviceFeatures.length === 1 ? deviceFeatures[0] : undefined; + } + return undefined; + } + + private lightPatch(requestArg: IServiceCallRequest, onArg: boolean): IBleboxLightStatePatch | { error: string } { + const brightness = requestArg.data?.brightness; + if (brightness !== undefined && !this.isByte(brightness)) { + return { error: 'BleBox light brightness must be an integer between 0 and 255.' }; + } + const rgbColor = this.colorTuple(requestArg.data?.rgb_color, 3, 'rgb_color'); + if ('error' in rgbColor) return rgbColor; + const rgbwColor = this.colorTuple(requestArg.data?.rgbw_color, 4, 'rgbw_color'); + if ('error' in rgbwColor) return rgbwColor; + const rgbwwColor = this.colorTuple(requestArg.data?.rgbww_color, 5, 'rgbww_color'); + if ('error' in rgbwwColor) return rgbwwColor; + const colorTempKelvin = requestArg.data?.color_temp_kelvin; + if (colorTempKelvin !== undefined && (typeof colorTempKelvin !== 'number' || !Number.isFinite(colorTempKelvin) || colorTempKelvin <= 0)) { + return { error: 'BleBox light color_temp_kelvin must be a positive number.' }; + } + const effect = requestArg.data?.effect; + if (effect !== undefined && typeof effect !== 'string' && typeof effect !== 'number') { + return { error: 'BleBox light effect must be a string name or numeric effect id.' }; + } + + return { + on: onArg, + brightness: typeof brightness === 'number' ? brightness : undefined, + rgbColor: rgbColor.value as [number, number, number] | undefined, + rgbwColor: rgbwColor.value as [number, number, number, number] | undefined, + rgbwwColor: rgbwwColor.value as [number, number, number, number, number] | undefined, + colorTempKelvin: typeof colorTempKelvin === 'number' ? colorTempKelvin : undefined, + effect: typeof effect === 'string' || typeof effect === 'number' ? effect : undefined, + }; + } + + private colorTuple(valueArg: unknown, lengthArg: number, nameArg: string): { value?: number[] } | { error: string } { + if (valueArg === undefined) { + return {}; + } + if (!Array.isArray(valueArg) || valueArg.length !== lengthArg || valueArg.some((entryArg) => !this.isByte(entryArg))) { + return { error: `BleBox light ${nameArg} must be an array of ${lengthArg} integers between 0 and 255.` }; + } + return { value: valueArg as number[] }; + } + + private isByte(valueArg: unknown): valueArg is number { + return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 255; + } + + private isPercent(valueArg: unknown): valueArg is number { + return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 100; } } diff --git a/ts/integrations/blebox/blebox.discovery.ts b/ts/integrations/blebox/blebox.discovery.ts new file mode 100644 index 0000000..eb882b4 --- /dev/null +++ b/ts/integrations/blebox/blebox.discovery.ts @@ -0,0 +1,143 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IBleboxManualEntry, IBleboxMdnsRecord } from './blebox.types.js'; + +const bleboxMdnsType = '_bbxsrv._tcp.local'; +const knownProducts = new Set([ + 'airsensor', + 'dimmerbox', + 'gatebox', + 'gatecontroller', + 'multisensor', + 'shutterbox', + 'switchbox', + 'switchboxd', + 'tempsensor', + 'wlightbox', + 'wlightboxs', +]); + +export class BleboxMdnsMatcher implements IDiscoveryMatcher { + public id = 'blebox-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize BleBox zeroconf records advertised as _bbxsrv._tcp.local.'; + + public async matches(recordArg: IBleboxMdnsRecord): Promise { + const type = normalizeMdnsType(recordArg.type); + const name = recordArg.name?.toLowerCase() || ''; + const model = recordArg.txt?.type || recordArg.txt?.product || recordArg.txt?.model; + const matched = type === bleboxMdnsType || name.includes('blebox') || isKnownProduct(model); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'mDNS record is not a BleBox advertisement.', + }; + } + + const id = recordArg.txt?.id || recordArg.txt?.mac || recordArg.name; + return { + matched: true, + confidence: type === bleboxMdnsType ? 'certain' : 'high', + reason: 'mDNS record matches BleBox zeroconf metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'blebox', + id, + host: recordArg.host, + port: recordArg.port || 80, + name: recordArg.txt?.deviceName || recordArg.name, + manufacturer: 'BleBox', + model, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + }, + }, + }; + } +} + +export class BleboxManualMatcher implements IDiscoveryMatcher { + public id = 'blebox-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual BleBox setup entries by host, manufacturer, model, or metadata.'; + + public async matches(inputArg: IBleboxManualEntry): Promise { + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const model = inputArg.model?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || manufacturer === 'blebox' || isKnownProduct(model) || inputArg.metadata?.blebox); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'Manual entry does not contain BleBox setup hints.', + }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start BleBox setup.', + normalizedDeviceId: inputArg.id, + candidate: { + source: 'manual', + integrationDomain: 'blebox', + id: inputArg.id, + host: inputArg.host, + port: inputArg.port || 80, + name: inputArg.name, + manufacturer: 'BleBox', + model: inputArg.model, + metadata: inputArg.metadata, + }, + }; + } +} + +export class BleboxCandidateValidator implements IDiscoveryValidator { + public id = 'blebox-candidate-validator'; + public description = 'Validate BleBox candidate metadata before starting local HTTP setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'blebox' + || manufacturer === 'blebox' + || isKnownProduct(model) + || Boolean(candidateArg.metadata?.blebox); + const hasUsableAddress = Boolean(candidateArg.host && (!candidateArg.port || isValidPort(candidateArg.port))); + return { + matched: matched && hasUsableAddress, + confidence: matched && candidateArg.id ? 'certain' : matched && hasUsableAddress ? 'high' : matched ? 'medium' : 'low', + reason: matched + ? hasUsableAddress ? 'Candidate has BleBox metadata and a usable HTTP address.' : 'Candidate has BleBox metadata but no usable HTTP address.' + : 'Candidate is not BleBox.', + candidate: matched && hasUsableAddress ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createBleboxDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'blebox', + displayName: 'BleBox devices', + }) + .addMatcher(new BleboxMdnsMatcher()) + .addMatcher(new BleboxManualMatcher()) + .addValidator(new BleboxCandidateValidator()); +}; + +const normalizeMdnsType = (valueArg?: string): string => { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); +}; + +const isKnownProduct = (valueArg?: string): boolean => { + return Boolean(valueArg && knownProducts.has(valueArg.toLowerCase())); +}; + +const isValidPort = (valueArg: number): boolean => { + return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535; +}; diff --git a/ts/integrations/blebox/blebox.mapper.ts b/ts/integrations/blebox/blebox.mapper.ts new file mode 100644 index 0000000..ab33969 --- /dev/null +++ b/ts/integrations/blebox/blebox.mapper.ts @@ -0,0 +1,577 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import { normalizedProductType } from './blebox.classes.client.js'; +import type { + IBleboxBinarySensorFeature, + IBleboxCoverFeature, + IBleboxDeviceInfo, + IBleboxLightFeature, + IBleboxSensorFeature, + IBleboxSnapshot, + IBleboxSwitchFeature, + TBleboxCoverStateName, + TBleboxFeature, + TBleboxJsonValue, +} from './blebox.types.js'; + +const sensorMetadata: Record = { + activePower: { unit: 'W', deviceClass: 'power' }, + apparentPower: { unit: 'VA', deviceClass: 'apparent_power' }, + current: { unit: 'mA', deviceClass: 'current' }, + forwardActiveEnergy: { unit: 'kWh', deviceClass: 'energy', scale: 1000 }, + frequency: { unit: 'Hz', deviceClass: 'frequency', scale: 1000 }, + humidity: { unit: '%', deviceClass: 'humidity', scale: 100 }, + illuminance: { unit: 'lx', deviceClass: 'illuminance', scale: 100 }, + pm1: { unit: 'ug/m3', deviceClass: 'pm1' }, + pm2_5: { unit: 'ug/m3', deviceClass: 'pm25' }, + pm10: { unit: 'ug/m3', deviceClass: 'pm10' }, + powerConsumption: { unit: 'kWh', deviceClass: 'energy' }, + reactivePower: { unit: 'var', deviceClass: 'power' }, + reverseActiveEnergy: { unit: 'kWh', deviceClass: 'energy', scale: 1000 }, + temperature: { unit: 'C', deviceClass: 'temperature', scale: 100 }, + voltage: { unit: 'V', deviceClass: 'voltage', scale: 10 }, + wind: { unit: 'm/s', deviceClass: 'wind_speed', scale: 10 }, +}; + +export class BleboxMapper { + public static toDevices(snapshotArg: IBleboxSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const deviceId = this.deviceId(snapshotArg); + 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: 'online', updatedAt }, + ]; + + for (const feature of this.features(snapshotArg)) { + if (feature.platform === 'switch') { + features.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') { + features.push({ id: feature.id, capability: 'light', name: feature.name, readable: true, writable: true }); + state.push({ featureId: feature.id, value: feature.isOn, updatedAt }); + if (typeof feature.brightness === 'number') { + features.push({ id: `${feature.id}_brightness`, capability: 'light', name: `${feature.name} brightness`, readable: true, writable: true, unit: '%' }); + state.push({ featureId: `${feature.id}_brightness`, value: Math.round(feature.brightness / 255 * 100), updatedAt }); + } + } else if (feature.platform === 'cover') { + features.push({ id: feature.id, capability: 'cover', name: feature.name, readable: true, writable: true, unit: '%' }); + state.push({ featureId: feature.id, value: feature.stateName, updatedAt }); + if (feature.position !== null) { + features.push({ id: `${feature.id}_position`, capability: 'cover', name: `${feature.name} position`, readable: true, writable: feature.supportsPosition, unit: '%' }); + state.push({ featureId: `${feature.id}_position`, value: feature.position, updatedAt }); + } + } else if (feature.platform === 'sensor') { + features.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') { + features.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false }); + state.push({ featureId: feature.id, value: feature.isOn, updatedAt }); + } + } + + return [{ + id: deviceId, + integrationDomain: 'blebox', + name: snapshotArg.device.deviceName || 'BleBox device', + protocol: 'http', + manufacturer: 'BleBox', + model: this.model(snapshotArg.device), + online: true, + features, + state, + metadata: { + firmwareVersion: snapshotArg.device.fv, + hardwareVersion: snapshotArg.device.hv, + apiLevel: snapshotArg.device.apiLevel, + host: snapshotArg.host || snapshotArg.device.ip, + port: snapshotArg.port, + }, + }]; + } + + public static toEntities(snapshotArg: IBleboxSnapshot): IIntegrationEntity[] { + const deviceId = this.deviceId(snapshotArg); + const deviceSlug = this.slug(snapshotArg.device.deviceName || snapshotArg.device.id || 'blebox'); + const entities: IIntegrationEntity[] = []; + + for (const feature of this.features(snapshotArg)) { + const featureSlug = this.slug(feature.alias); + if (feature.platform === 'switch') { + entities.push({ + id: `switch.${deviceSlug}_${featureSlug}`, + uniqueId: feature.uniqueId, + integrationDomain: 'blebox', + deviceId, + platform: 'switch', + name: feature.name, + state: feature.isOn ? 'on' : 'off', + attributes: { unitId: feature.unitId, deviceClass: feature.deviceClass }, + available: feature.isOn !== null, + }); + } else if (feature.platform === 'light') { + entities.push({ + id: `light.${deviceSlug}_${featureSlug}`, + uniqueId: feature.uniqueId, + integrationDomain: 'blebox', + deviceId, + platform: 'light', + name: feature.name, + state: feature.isOn ? 'on' : 'off', + attributes: { + brightness: feature.brightness, + colorMode: feature.colorModeName, + desiredColor: feature.desiredColor, + effect: feature.effect, + effectList: feature.effectList, + supportsColor: feature.supportsColor, + supportsWhite: feature.supportsWhite, + }, + available: feature.isOn !== null, + }); + } else if (feature.platform === 'cover') { + entities.push({ + id: `cover.${deviceSlug}_${featureSlug}`, + uniqueId: feature.uniqueId, + integrationDomain: 'blebox', + deviceId, + platform: 'cover', + name: feature.name, + state: feature.stateName, + attributes: { + currentPosition: feature.position, + currentTiltPosition: feature.tiltPosition, + deviceClass: feature.deviceClass, + supportsStop: feature.supportsStop, + supportsPosition: feature.supportsPosition, + supportsTilt: feature.supportsTilt, + }, + available: feature.stateName !== 'unknown', + }); + } else if (feature.platform === 'sensor') { + entities.push({ + id: `sensor.${deviceSlug}_${featureSlug}`, + uniqueId: feature.uniqueId, + integrationDomain: 'blebox', + deviceId, + platform: 'sensor', + name: feature.name, + state: feature.nativeValue, + attributes: { + unit: feature.unit, + deviceClass: feature.deviceClass, + sensorType: feature.sensorType, + sensorId: feature.sensorId, + }, + available: feature.nativeValue !== null, + }); + } else if (feature.platform === 'binary_sensor') { + entities.push({ + id: `binary_sensor.${deviceSlug}_${featureSlug}`, + uniqueId: feature.uniqueId, + integrationDomain: 'blebox', + deviceId, + platform: 'binary_sensor', + name: feature.name, + state: feature.isOn ? 'on' : 'off', + attributes: { + deviceClass: feature.deviceClass, + sensorType: feature.sensorType, + sensorId: feature.sensorId, + }, + available: true, + }); + } + } + + return entities; + } + + public static features(snapshotArg: IBleboxSnapshot): TBleboxFeature[] { + return [ + ...this.switchFeatures(snapshotArg), + ...this.lightFeatures(snapshotArg), + ...this.coverFeatures(snapshotArg), + ...this.sensorFeatures(snapshotArg), + ...this.binarySensorFeatures(snapshotArg), + ]; + } + + public static deviceId(snapshotArg: IBleboxSnapshot): string { + return `blebox.device.${this.slug(snapshotArg.device.id || snapshotArg.device.deviceName || 'unknown')}`; + } + + private static switchFeatures(snapshotArg: IBleboxSnapshot): IBleboxSwitchFeature[] { + const productType = normalizedProductType(snapshotArg.device); + if (!['switchBox', 'switchBoxD'].includes(productType)) { + return []; + } + const root = this.mergedRecord(snapshotArg); + const relays = this.relayStates(root); + const stateRelays = relays.length > 0 ? relays : this.relayStates(asArray(snapshotArg.state) || []); + return stateRelays.map((relayArg) => { + const alias = `relay_${relayArg.unitId}`; + return { + platform: 'switch', + id: `switch_${relayArg.unitId}`, + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: relayArg.name || `${snapshotArg.device.deviceName || 'BleBox'} relay ${relayArg.unitId}`, + unitId: relayArg.unitId, + isOn: relayArg.state === undefined ? null : relayArg.state === 1 || relayArg.state === true, + deviceClass: 'switch', + }; + }); + } + + private static lightFeatures(snapshotArg: IBleboxSnapshot): IBleboxLightFeature[] { + const productType = normalizedProductType(snapshotArg.device); + const root = this.mergedRecord(snapshotArg); + if (productType === 'dimmerBox') { + const dimmer = asRecord(root.dimmer); + const brightness = toNumber(dimmer?.desiredBrightness ?? dimmer?.currentBrightness); + const alias = 'brightness'; + return [{ + platform: 'light', + id: 'light_brightness', + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: `${snapshotArg.device.deviceName || 'BleBox'} brightness`, + isOn: brightness === undefined ? null : brightness > 0, + brightness: brightness ?? null, + colorModeName: 'brightness', + supportsColor: false, + supportsWhite: false, + }]; + } + if (!['wLightBox', 'wLightBoxS'].includes(productType)) { + return []; + } + + const lightState = asRecord(root.rgbw) || asRecord(root.light); + if (!lightState) { + return []; + } + const desiredColor = typeof lightState.desiredColor === 'string' ? lightState.desiredColor.replace(/-/g, '0') : undefined; + const colorMode = toNumber(lightState.colorMode); + const effectId = toNumber(lightState.effectID); + const effectList = this.effectList(lightState.effectsNames); + const effect = effectId === undefined ? undefined : effectList[effectId] ?? effectId; + const alias = 'color'; + return [{ + platform: 'light', + id: 'light_color', + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: `${snapshotArg.device.deviceName || 'BleBox'} color`, + isOn: desiredColor === undefined ? null : Number.parseInt(desiredColor || '0', 16) > 0 || Boolean(effectId && effectId > 0), + brightness: desiredColor ? this.brightnessFromHex(desiredColor, colorMode) : null, + desiredColor: desiredColor ?? null, + colorMode: colorMode ?? null, + colorModeName: this.colorModeName(colorMode, productType), + effect: effect ?? null, + effectList, + supportsColor: productType === 'wLightBox' && colorMode !== 3, + supportsWhite: productType === 'wLightBox' && [1, 4, 7].includes(colorMode ?? 0), + }]; + } + + private static coverFeatures(snapshotArg: IBleboxSnapshot): IBleboxCoverFeature[] { + const productType = normalizedProductType(snapshotArg.device); + const root = this.mergedRecord(snapshotArg); + const alias = 'position'; + const base = { + platform: 'cover' as const, + id: 'cover_position', + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: `${snapshotArg.device.deviceName || 'BleBox'} position`, + supportsOpen: true, + supportsClose: true, + }; + + if (productType === 'shutterBox') { + const shutter = asRecord(root.shutter); + if (!shutter) return []; + const desiredPos = asRecord(shutter.desiredPos); + const currentPos = asRecord(shutter.currentPos); + const rawPosition = toNumber(desiredPos?.position ?? currentPos?.position); + const rawTilt = toNumber(desiredPos?.tilt ?? currentPos?.tilt); + const state = toNumber(shutter.state); + return [{ + ...base, + deviceClass: 'shutter', + state: state ?? null, + stateName: this.coverStateName(state), + position: this.invertPosition(rawPosition), + tiltPosition: this.invertPosition(rawTilt), + supportsStop: true, + supportsPosition: true, + supportsTilt: toNumber(shutter.controlType) === 3 || rawTilt !== undefined, + isPositionInverted: true, + }]; + } + + if (productType === 'gateController') { + const gateController = asRecord(root.gateController); + if (!gateController) return []; + const desiredPos = asRecord(gateController.desiredPos); + const positions = asArray(desiredPos?.positions); + const rawPosition = toNumber(positions?.[0]); + const state = toNumber(gateController.state); + return [{ + ...base, + deviceClass: 'gate', + state: state ?? null, + stateName: this.coverStateName(state), + position: this.invertPosition(rawPosition), + supportsStop: true, + supportsPosition: true, + supportsTilt: false, + isPositionInverted: true, + }]; + } + + if (productType === 'gateBox') { + const nestedGate = asRecord(root.gate); + const gate = nestedGate || root; + const rawPosition = toNumber(gate.currentPos ?? gate.desiredPos); + const rawDesired = toNumber(gate.desiredPos ?? gate.currentPos); + const mappedPosition = nestedGate ? rawPosition : rawDesired; + const openCloseMode = toNumber(gate.openCloseMode); + const state = this.gateBoxState(rawPosition, rawDesired); + return [{ + ...base, + deviceClass: 'door', + state, + stateName: this.coverStateName(state), + position: mappedPosition === -1 || mappedPosition === undefined ? null : mappedPosition, + supportsStop: openCloseMode === undefined ? toNumber(gate.extraButtonType) === 1 : openCloseMode !== 2, + supportsPosition: false, + supportsTilt: false, + isPositionInverted: false, + }]; + } + + return []; + } + + private static sensorFeatures(snapshotArg: IBleboxSnapshot): IBleboxSensorFeature[] { + const root = this.mergedRecord(snapshotArg); + const sensors = this.sensorStates(root).filter((sensorArg) => !['rain', 'flood'].includes(sensorArg.type)); + return sensors.map((sensorArg) => { + const metadata = sensorMetadata[sensorArg.type] || {}; + const scale = metadata.scale || 1; + const value = typeof sensorArg.value === 'number' ? sensorArg.value / scale : sensorArg.value ?? null; + const alias = sensorArg.id === undefined ? sensorArg.type : `${sensorArg.type}_${sensorArg.id}`; + return { + platform: 'sensor', + id: `sensor_${this.slug(alias)}`, + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: `${snapshotArg.device.deviceName || 'BleBox'} ${this.humanize(sensorArg.type)}`, + sensorType: sensorArg.type, + sensorId: sensorArg.id, + nativeValue: typeof value === 'number' && Number.isFinite(value) ? Number(value.toFixed(3)) : value, + unit: metadata.unit, + deviceClass: metadata.deviceClass || sensorArg.type, + }; + }); + } + + private static binarySensorFeatures(snapshotArg: IBleboxSnapshot): IBleboxBinarySensorFeature[] { + const root = this.mergedRecord(snapshotArg); + return this.sensorStates(root) + .filter((sensorArg) => ['rain', 'flood'].includes(sensorArg.type)) + .map((sensorArg) => { + const alias = sensorArg.id === undefined ? sensorArg.type : `${sensorArg.type}_${sensorArg.id}`; + return { + platform: 'binary_sensor', + id: `binary_sensor_${this.slug(alias)}`, + alias, + uniqueId: this.uniqueId(snapshotArg.device, alias), + name: `${snapshotArg.device.deviceName || 'BleBox'} ${this.humanize(sensorArg.type)}`, + sensorType: sensorArg.type, + sensorId: sensorArg.id, + isOn: Number(sensorArg.value || 0) > 0, + deviceClass: 'moisture', + }; + }); + } + + private static mergedRecord(snapshotArg: IBleboxSnapshot): Record { + const state = asRecord(snapshotArg.state) || {}; + const extendedState = asRecord(snapshotArg.extendedState) || {}; + const rawInfo = asRecord(snapshotArg.rawInfo) || {}; + return { ...rawInfo, ...state, ...extendedState }; + } + + private static relayStates(rootArg: Record | TBleboxJsonValue[]): Array<{ unitId: number; state?: number | boolean; name?: string }> { + if (Array.isArray(rootArg)) { + const oldRelays = rootArg + .map((relayArg) => asRecord(relayArg)) + .filter((relayArg): relayArg is Record => Boolean(relayArg)); + return oldRelays.map((relayArg, indexArg) => ({ + unitId: toNumber(relayArg.relay) ?? indexArg, + state: toNumber(relayArg.state) ?? (typeof relayArg.state === 'boolean' ? relayArg.state : undefined), + name: typeof relayArg.name === 'string' ? relayArg.name : undefined, + })); + } + + const relays = asArray(rootArg.relays) + ?.map((relayArg) => asRecord(relayArg)) + .filter((relayArg): relayArg is Record => Boolean(relayArg)) || []; + if (relays.length > 0) { + return relays.map((relayArg, indexArg) => ({ + unitId: toNumber(relayArg.relay) ?? indexArg, + state: toNumber(relayArg.state) ?? (typeof relayArg.state === 'boolean' ? relayArg.state : undefined), + name: typeof relayArg.name === 'string' ? relayArg.name : undefined, + })); + } + + return []; + } + + private static sensorStates(rootArg: Record): Array<{ type: string; id?: string | number; value?: number | string }> { + const sensors: Array<{ type: string; id?: string | number; value?: number | string }> = []; + const multiSensor = asRecord(rootArg.multiSensor); + this.pushSensorArray(sensors, asArray(multiSensor?.sensors)); + this.pushSensorArray(sensors, asArray(rootArg.sensors)); + + const air = asRecord(rootArg.air); + for (const airSensor of asArray(air?.sensors) || []) { + const record = asRecord(airSensor); + const rawType = typeof record?.type === 'string' ? record.type : undefined; + if (!rawType) continue; + sensors.push({ type: this.normalizeSensorType(rawType), value: primitiveNumberOrString(record?.value) }); + } + + const tempSensor = asRecord(rootArg.tempSensor); + for (const temperatureSensor of asArray(tempSensor?.sensors) || []) { + const record = asRecord(temperatureSensor); + sensors.push({ type: 'temperature', id: primitiveId(record?.id), value: primitiveNumberOrString(record?.value) }); + } + + const powerMeasuring = asRecord(rootArg.powerMeasuring); + for (const power of asArray(powerMeasuring?.powerConsumption) || []) { + const record = asRecord(power); + sensors.push({ type: 'powerConsumption', value: primitiveNumberOrString(record?.value) }); + } + + return sensors.filter((sensorArg) => sensorArg.value !== undefined); + } + + private static pushSensorArray(targetArg: Array<{ type: string; id?: string | number; value?: number | string }>, arrayArg?: TBleboxJsonValue[]): void { + for (const sensor of arrayArg || []) { + const record = asRecord(sensor); + const rawType = typeof record?.type === 'string' ? record.type : undefined; + if (!rawType) continue; + targetArg.push({ + type: this.normalizeSensorType(rawType), + id: primitiveId(record?.id), + value: primitiveNumberOrString(record?.value), + }); + } + } + + private static gateBoxState(currentArg?: number, desiredArg?: number): number | null { + if (currentArg === undefined || desiredArg === undefined || currentArg === -1) return null; + if (desiredArg < currentArg) return 0; + if (desiredArg > currentArg) return 1; + if (currentArg === 0) return 3; + if (currentArg === 100) return 4; + return 2; + } + + private static coverStateName(stateArg?: number | null): TBleboxCoverStateName { + if (stateArg === 0) return 'closing'; + if (stateArg === 1) return 'opening'; + if (stateArg === 3) return 'closed'; + if ([2, 4, 5, 6, 8].includes(stateArg ?? -1)) return 'open'; + return 'unknown'; + } + + private static invertPosition(positionArg?: number): number | null { + if (positionArg === undefined || positionArg === -1) return null; + return 100 - positionArg; + } + + private static brightnessFromHex(valueArg: string, colorModeArg?: number): number { + const elements = valueArg.match(/../g)?.map((value) => Number.parseInt(value, 16)) || []; + if (elements.length === 0) return 0; + if ([5, 6].includes(colorModeArg ?? 0) && elements.length >= 2) { + return Math.max(elements[0], elements[1]); + } + return Math.max(...elements.slice(0, Math.min(elements.length, 4))); + } + + private static effectList(valueArg: TBleboxJsonValue | undefined): string[] { + const effects = asRecord(valueArg); + if (!effects) return []; + return Object.keys(effects) + .sort((leftArg, rightArg) => Number(leftArg) - Number(rightArg)) + .map((keyArg) => effects[keyArg]) + .filter((effectArg): effectArg is string => typeof effectArg === 'string'); + } + + private static colorModeName(valueArg: number | undefined, productTypeArg: string): string { + if (productTypeArg === 'dimmerBox') return 'brightness'; + return ({ + 1: 'rgbw', + 2: 'rgb', + 3: 'brightness', + 4: 'rgbw', + 5: 'color_temp', + 6: 'color_temp', + 7: 'rgbww', + } as Record)[valueArg ?? 0] || 'onoff'; + } + + private static normalizeSensorType(valueArg: string): string { + return valueArg === 'pm2.5' ? 'pm2_5' : valueArg; + } + + private static model(deviceArg: IBleboxDeviceInfo): string { + return normalizedProductType(deviceArg); + } + + private static uniqueId(deviceArg: IBleboxDeviceInfo, aliasArg: string): string { + return `BleBox-${this.model(deviceArg)}-${deviceArg.id || 'unknown'}-${aliasArg}`; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'blebox'; + } + + private static humanize(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); + } +} + +const asRecord = (valueArg: unknown): Record | undefined => { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) + ? valueArg as Record + : undefined; +}; + +const asArray = (valueArg: unknown): TBleboxJsonValue[] | undefined => { + return Array.isArray(valueArg) ? valueArg as TBleboxJsonValue[] : undefined; +}; + +const toNumber = (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; +}; + +const primitiveId = (valueArg: unknown): string | number | undefined => { + return typeof valueArg === 'string' || typeof valueArg === 'number' ? valueArg : undefined; +}; + +const primitiveNumberOrString = (valueArg: unknown): number | string | undefined => { + return typeof valueArg === 'number' || typeof valueArg === 'string' ? valueArg : undefined; +}; diff --git a/ts/integrations/blebox/blebox.types.ts b/ts/integrations/blebox/blebox.types.ts index 1ad1ec6..4d6479b 100644 --- a/ts/integrations/blebox/blebox.types.ts +++ b/ts/integrations/blebox/blebox.types.ts @@ -1,4 +1,175 @@ -export interface IHomeAssistantBleboxConfig { - // TODO: replace with the TypeScript-native config for blebox. - [key: string]: unknown; +export type TBleboxProtocol = 'http'; + +export type TBleboxEntityPlatform = 'switch' | 'light' | 'cover' | 'sensor' | 'binary_sensor'; + +export type TBleboxJsonValue = string | number | boolean | null | TBleboxJsonValue[] | { + [key: string]: TBleboxJsonValue | undefined; +}; + +export interface IBleboxConfig { + host?: string; + port?: number; + protocol?: TBleboxProtocol; + username?: string; + password?: string; + timeoutMs?: number; + snapshot?: IBleboxSnapshot; } + +export interface IBleboxDeviceInfo { + id?: string; + type?: string; + product?: string; + deviceName?: string; + fv?: string; + hv?: string; + apiLevel?: string | number; + ip?: string; + availableFv?: string | null; + [key: string]: TBleboxJsonValue | undefined; +} + +export interface IBleboxProductProfile { + productType: string; + model: string; + apiLevel: number; + apiPath: string; + extendedStatePath?: string; + supports: Partial>; +} + +export interface IBleboxSnapshot { + device: IBleboxDeviceInfo; + state?: TBleboxJsonValue; + extendedState?: TBleboxJsonValue; + rawInfo?: TBleboxJsonValue; + host?: string; + port?: number; + profile?: IBleboxProductProfile; +} + +export interface IBleboxSwitchFeature { + platform: 'switch'; + id: string; + alias: string; + uniqueId: string; + name: string; + unitId: number; + isOn: boolean | null; + deviceClass: 'switch'; +} + +export interface IBleboxLightFeature { + platform: 'light'; + id: string; + alias: string; + uniqueId: string; + name: string; + isOn: boolean | null; + brightness?: number | null; + desiredColor?: string | null; + colorMode?: number | null; + colorModeName?: string; + effect?: string | number | null; + effectList?: string[]; + supportsColor?: boolean; + supportsWhite?: boolean; +} + +export type TBleboxCoverStateName = 'opening' | 'closing' | 'open' | 'closed' | 'unknown'; + +export interface IBleboxCoverFeature { + platform: 'cover'; + id: string; + alias: string; + uniqueId: string; + name: string; + deviceClass: 'gate' | 'door' | 'shutter'; + state: number | null; + stateName: TBleboxCoverStateName; + position: number | null; + tiltPosition?: number | null; + supportsOpen: boolean; + supportsClose: boolean; + supportsStop: boolean; + supportsPosition: boolean; + supportsTilt: boolean; + isPositionInverted: boolean; +} + +export interface IBleboxSensorFeature { + platform: 'sensor'; + id: string; + alias: string; + uniqueId: string; + name: string; + sensorType: string; + sensorId?: string | number; + nativeValue: number | string | null; + unit?: string; + deviceClass?: string; +} + +export interface IBleboxBinarySensorFeature { + platform: 'binary_sensor'; + id: string; + alias: string; + uniqueId: string; + name: string; + sensorType: 'rain' | 'flood' | string; + sensorId?: string | number; + isOn: boolean; + deviceClass: 'moisture'; +} + +export type TBleboxFeature = + | IBleboxSwitchFeature + | IBleboxLightFeature + | IBleboxCoverFeature + | IBleboxSensorFeature + | IBleboxBinarySensorFeature; + +export interface IBleboxLightStatePatch { + on: boolean; + brightness?: number; + rgbColor?: [number, number, number]; + rgbwColor?: [number, number, number, number]; + rgbwwColor?: [number, number, number, number, number]; + colorTempKelvin?: number; + effect?: string | number; +} + +export type TBleboxCoverCommand = + | 'open_cover' + | 'close_cover' + | 'stop_cover' + | 'set_cover_position' + | 'open_cover_tilt' + | 'close_cover_tilt' + | 'set_cover_tilt_position'; + +export interface IBleboxCoverCommandRequest { + command: TBleboxCoverCommand; + position?: number; + tiltPosition?: number; +} + +export interface IBleboxMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IBleboxManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + metadata?: Record; +} + +export interface IHomeAssistantBleboxConfig extends IBleboxConfig {} diff --git a/ts/integrations/blebox/index.ts b/ts/integrations/blebox/index.ts index 21b4399..c2fc698 100644 --- a/ts/integrations/blebox/index.ts +++ b/ts/integrations/blebox/index.ts @@ -1,2 +1,6 @@ +export * from './blebox.classes.client.js'; +export * from './blebox.classes.configflow.js'; export * from './blebox.classes.integration.js'; +export * from './blebox.discovery.js'; +export * from './blebox.mapper.js'; export * from './blebox.types.js'; diff --git a/ts/integrations/broadlink/.generated-by-smarthome-exchange b/ts/integrations/broadlink/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/broadlink/.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/broadlink/broadlink.classes.client.ts b/ts/integrations/broadlink/broadlink.classes.client.ts new file mode 100644 index 0000000..9594640 --- /dev/null +++ b/ts/integrations/broadlink/broadlink.classes.client.ts @@ -0,0 +1,167 @@ +import type { + IBroadlinkCommand, + IBroadlinkCommandResult, + IBroadlinkConfig, + IBroadlinkEvent, + IBroadlinkPacket, + IBroadlinkSnapshot, +} from './broadlink.types.js'; +import { BroadlinkMapper } from './broadlink.mapper.js'; +import { + broadlinkIrPacketFromTimings, + broadlinkPacketFromBase64, + broadlinkPacketFromHex, + broadlinkRfPacketFromTimings, +} from './broadlink.packet.js'; + +type TBroadlinkEventHandler = (eventArg: IBroadlinkEvent) => void; + +export class BroadlinkClient { + private snapshot?: IBroadlinkSnapshot; + private readonly events: IBroadlinkEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IBroadlinkConfig) {} + + public async getSnapshot(): Promise { + this.snapshot = BroadlinkMapper.toSnapshot(this.config, undefined, this.events); + return this.cloneSnapshot(this.snapshot); + } + + public onEvent(handlerArg: TBroadlinkEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async refresh(): Promise { + this.snapshot = BroadlinkMapper.toSnapshot(this.config, this.config.connected, this.events); + this.emit({ type: 'snapshot_refreshed', data: this.cloneSnapshot(this.snapshot), timestamp: Date.now() }); + return this.cloneSnapshot(this.snapshot); + } + + public async sendCommand(commandArg: IBroadlinkCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() }); + + if (commandArg.type === 'delete_command') { + const result = this.deleteCommand(commandArg); + this.emit({ type: result.success ? 'command_deleted' : 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, timestamp: Date.now() }); + return result; + } + + if (commandArg.type === 'remote_turn_on' || commandArg.type === 'remote_turn_off') { + const result: IBroadlinkCommandResult = { success: true, transmitted: false, data: { command: commandArg, reason: 'Remote entity enable state is local runtime state only.' } }; + this.emit({ type: 'command_executed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, timestamp: Date.now() }); + return result; + } + + const executor = this.config.commandExecutor || this.config.transport?.execute.bind(this.config.transport); + if (!executor) { + const result: IBroadlinkCommandResult = { + success: false, + transmitted: false, + error: this.unsupportedLiveControlMessage(), + data: { command: commandArg }, + }; + this.emit({ type: 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, timestamp: Date.now() }); + return result; + } + + try { + const result = this.commandResult(await executor(commandArg), commandArg); + if (result.success) { + this.patchSnapshot(commandArg); + } + this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, timestamp: Date.now() }); + return result; + } catch (errorArg) { + const result: IBroadlinkCommandResult = { + success: false, + transmitted: false, + error: errorArg instanceof Error ? errorArg.message : String(errorArg), + data: { command: commandArg }, + }; + this.emit({ type: 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, timestamp: Date.now() }); + return result; + } + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + public static packetFromBase64(valueArg: string): IBroadlinkPacket { + return broadlinkPacketFromBase64(valueArg); + } + + public static packetFromHex(valueArg: string): IBroadlinkPacket { + return broadlinkPacketFromHex(valueArg); + } + + public static irPacketFromTimings(timingsArg: number[]): IBroadlinkPacket { + return broadlinkIrPacketFromTimings(timingsArg); + } + + public static rfPacketFromTimings(optionsArg: { frequency: number; timings: number[]; repeatCount?: number }): IBroadlinkPacket { + return broadlinkRfPacketFromTimings(optionsArg); + } + + private emit(eventArg: IBroadlinkEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IBroadlinkCommand): IBroadlinkCommandResult { + if (this.isCommandResult(resultArg)) { + return { transmitted: true, ...resultArg }; + } + return { success: true, transmitted: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IBroadlinkCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private deleteCommand(commandArg: IBroadlinkCommand): IBroadlinkCommandResult { + const remoteDevice = commandArg.remoteDevice; + if (!remoteDevice || !commandArg.commandNames?.length) { + return { success: false, transmitted: false, error: 'Broadlink delete_command requires data.device and data.command.' }; + } + const devices = this.config.snapshot?.devices || this.config.devices || []; + const targetDevice = devices.find((deviceArg) => BroadlinkMapper.deviceId(deviceArg) === commandArg.deviceId || deviceArg.id === commandArg.deviceId) || devices[0]; + const codes = targetDevice?.codes || this.config.codes; + if (!codes?.[remoteDevice]) { + return { success: false, transmitted: false, error: `Broadlink learned-code device not found: ${remoteDevice}.` }; + } + for (const command of commandArg.commandNames) { + delete codes[remoteDevice][command]; + } + if (!Object.keys(codes[remoteDevice]).length) { + delete codes[remoteDevice]; + } + return { success: true, transmitted: false, data: { command: commandArg } }; + } + + private patchSnapshot(commandArg: IBroadlinkCommand): void { + const snapshot = this.snapshot; + if (!snapshot || !commandArg.deviceId || !commandArg.payload) { + return; + } + const device = snapshot.devices.find((deviceArg) => BroadlinkMapper.deviceId(deviceArg) === commandArg.deviceId || deviceArg.id === commandArg.deviceId); + if (!device) { + return; + } + device.state = { ...(device.state || {}), ...commandArg.payload }; + device.available = true; + device.updatedAt = new Date().toISOString(); + } + + private cloneSnapshot(snapshotArg: IBroadlinkSnapshot): IBroadlinkSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IBroadlinkSnapshot; + } + + private unsupportedLiveControlMessage(): string { + return 'Broadlink live UDP/auth transport is not implemented in this dependency-free TypeScript port. Commands are mapped explicitly but not transmitted unless commandExecutor or transport.execute is provided.'; + } +} diff --git a/ts/integrations/broadlink/broadlink.classes.configflow.ts b/ts/integrations/broadlink/broadlink.classes.configflow.ts new file mode 100644 index 0000000..827bbad --- /dev/null +++ b/ts/integrations/broadlink/broadlink.classes.configflow.ts @@ -0,0 +1,99 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { broadlinkDefaultPort, broadlinkDefaultTimeoutSeconds } from './broadlink.constants.js'; +import type { IBroadlinkConfig, IBroadlinkSnapshot, TBroadlinkDeviceType } from './broadlink.types.js'; + +export class BroadlinkConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const metadata = candidateArg.metadata || {}; + const host = candidateArg.host || this.stringValue(metadata.host) || ''; + const deviceType = candidateArg.model || this.stringValue(metadata.deviceType) || ''; + const name = candidateArg.name || this.stringValue(metadata.name) || ''; + + return { + kind: 'form', + title: 'Connect Broadlink device', + description: 'Provide the local host and device identity. This TypeScript port maps snapshots and commands, and requires an injected executor for live Broadlink UDP/auth traffic.', + fields: [ + { name: 'host', label: host ? `Host (${host})` : 'Host', type: 'text', required: true }, + { name: 'port', label: `Port (${candidateArg.port || broadlinkDefaultPort})`, type: 'number' }, + { name: 'timeout', label: `Timeout seconds (${broadlinkDefaultTimeoutSeconds})`, type: 'number' }, + { name: 'name', label: name ? `Name (${name})` : 'Name', type: 'text' }, + { name: 'type', label: deviceType ? `Device type (${deviceType})` : 'Device type', type: 'text' }, + { name: 'macAddress', label: candidateArg.macAddress ? `MAC (${candidateArg.macAddress})` : 'MAC address', 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); + if (snapshot instanceof Error) { + return { kind: 'error', title: 'Invalid snapshot', error: snapshot.message }; + } + + const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.host; + if (!host && !snapshot) { + return { kind: 'error', title: 'Host required', error: 'Broadlink setup requires a host unless a config is created directly from a snapshot.' }; + } + + const metadata = candidateArg.metadata || {}; + const config: IBroadlinkConfig = { + host, + port: this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.port || broadlinkDefaultPort, + timeout: this.numberValue(valuesArg.timeout) || this.numberValue(metadata.timeout) || broadlinkDefaultTimeoutSeconds, + name: this.stringValue(valuesArg.name) || candidateArg.name, + macAddress: this.stringValue(valuesArg.macAddress) || candidateArg.macAddress, + deviceId: candidateArg.id, + type: (this.stringValue(valuesArg.type) || this.stringValue(metadata.deviceType) || candidateArg.model) as TBroadlinkDeviceType | undefined, + devtype: this.numberValue(metadata.devtype), + model: candidateArg.model, + manufacturer: candidateArg.manufacturer || 'Broadlink', + isLocked: metadata.isLocked === true, + snapshot, + metadata: { + discoverySource: candidateArg.source, + discoveryMetadata: metadata, + liveUdpAuthImplemented: false, + }, + }; + + return { + kind: 'done', + title: 'Broadlink device configured', + config, + }; + } + + private snapshotFromInput(valueArg: unknown): IBroadlinkSnapshot | undefined | Error { + const text = this.stringValue(valueArg); + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as IBroadlinkSnapshot; + if (!parsed || !Array.isArray(parsed.devices)) { + return new Error('Snapshot JSON must include a devices array.'); + } + 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; + } +} diff --git a/ts/integrations/broadlink/broadlink.classes.integration.ts b/ts/integrations/broadlink/broadlink.classes.integration.ts index 630632b..0f2ea73 100644 --- a/ts/integrations/broadlink/broadlink.classes.integration.ts +++ b/ts/integrations/broadlink/broadlink.classes.integration.ts @@ -1,29 +1,97 @@ -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 { BroadlinkClient } from './broadlink.classes.client.js'; +import { BroadlinkConfigFlow } from './broadlink.classes.configflow.js'; +import { createBroadlinkDiscoveryDescriptor } from './broadlink.discovery.js'; +import { BroadlinkMapper } from './broadlink.mapper.js'; +import type { IBroadlinkConfig } from './broadlink.types.js'; -export class HomeAssistantBroadlinkIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "broadlink", - displayName: "Broadlink", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/broadlink", - "upstreamDomain": "broadlink", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "broadlink==0.19.0" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@danielhiversen", - "@felipediel", - "@L-I-Am", - "@eifinger" - ] -}, - }); +export class BroadlinkIntegration extends BaseIntegration { + public readonly domain = 'broadlink'; + public readonly displayName = 'Broadlink'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createBroadlinkDiscoveryDescriptor(); + public readonly configFlow = new BroadlinkConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/broadlink', + upstreamDomain: 'broadlink', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['broadlink==0.19.0'], + dependencies: [], + afterDependencies: [], + codeowners: ['@danielhiversen', '@felipediel', '@L-I-Am', '@eifinger'], + documentation: 'https://www.home-assistant.io/integrations/broadlink', + configFlow: true, + discovery: { + dhcp: true, + manual: true, + localHelloRecords: true, + note: 'Home Assistant uses python-broadlink UDP hello/auth. This native port recognizes DHCP/manual/local hello candidates; live UDP is an injected executor boundary.', + }, + runtime: { + type: 'control-runtime', + polling: 'snapshot/manual', + services: ['remote.send_command', 'remote.learn_command', 'remote.delete_command', 'switch.turn_on', 'switch.turn_off', 'infrared.send_command', 'radio_frequency.send_command', 'broadlink.send_packet'], + liveUdpAuthImplemented: false, + }, + localApi: { + implemented: [ + 'Broadlink DHCP/manual/local hello discovery candidate mapping', + 'Config flow shape for host, MAC, device type, timeout, and optional snapshot JSON', + 'Snapshot mapping for RM remote entities, Broadlink switch/outlet slots, custom IR/RF switches, and safe sensor keys', + 'Packet encoding for Broadlink IR timings and 315/433 MHz RF timings', + 'Explicit service command shapes for learned-code send/delete, learn IR/RF, send packet, switch power, IR, and RF commands', + ], + explicitUnsupported: [ + 'Native Broadlink UDP discovery broadcast from this package', + 'Native Broadlink encrypted UDP auth/session transport', + 'Claiming live packets were sent without an injected commandExecutor or transport.execute', + ], + }, + }; + + public async setup(configArg: IBroadlinkConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new BroadlinkRuntime(new BroadlinkClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantBroadlinkIntegration extends BroadlinkIntegration {} + +class BroadlinkRuntime implements IIntegrationRuntime { + public domain = 'broadlink'; + + constructor(private readonly client: BroadlinkClient) {} + + public async devices(): Promise { + return BroadlinkMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return BroadlinkMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(BroadlinkMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = BroadlinkMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Broadlink service or target: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/broadlink/broadlink.constants.ts b/ts/integrations/broadlink/broadlink.constants.ts new file mode 100644 index 0000000..3ff2e0f --- /dev/null +++ b/ts/integrations/broadlink/broadlink.constants.ts @@ -0,0 +1,49 @@ +import type { TBroadlinkDeviceType } from './broadlink.types.js'; + +export const broadlinkDomain = 'broadlink'; +export const broadlinkDefaultPort = 80; +export const broadlinkDefaultTimeoutSeconds = 5; +export const broadlinkDefaultDelaySeconds = 0.4; +export const broadlinkIrTickUs = 32.84; + +export const broadlinkMacPrefixes = [ + '34ea34', + '24dfa7', + 'a043b0', + 'b4430d', + 'c8f742', + 'e81656', + 'e87072', + 'ec0bae', + '780f77', +]; + +export const broadlinkRemoteTypes = new Set(['RM4MINI', 'RM4PRO', 'RMMINI', 'RMMINIB', 'RMPRO']); +export const broadlinkRfRemoteTypes = new Set(['RM4PRO', 'RMPRO']); +export const broadlinkSwitchTypes = new Set(['BG1', 'MP1', 'MP1S', 'SP1', 'SP2', 'SP2S', 'SP3', 'SP3S', 'SP4', 'SP4B']); +export const broadlinkSensorTypes = new Set(['A1', 'A2', 'MP1S', 'RM4MINI', 'RM4PRO', 'RMPRO', 'SP2S', 'SP3S', 'SP4', 'SP4B']); + +export const broadlinkSupportedTypes = new Set([ + ...broadlinkRemoteTypes, + ...broadlinkSwitchTypes, + ...broadlinkSensorTypes, + 'HYS', + 'LB1', + 'LB2', +]); + +export const broadlinkSensorDescriptions: Record = { + air_quality: { name: 'Air quality', deviceClass: 'aqi' }, + current: { name: 'Current', unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + humidity: { name: 'Humidity', unit: '%', deviceClass: 'humidity', stateClass: 'measurement' }, + light: { name: 'Light' }, + noise: { name: 'Noise' }, + overload: { name: 'Overload', unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + pm1: { name: 'PM1', unit: 'µg/m³', deviceClass: 'pm1', stateClass: 'measurement' }, + pm10: { name: 'PM10', unit: 'µg/m³', deviceClass: 'pm10', stateClass: 'measurement' }, + pm2_5: { name: 'PM2.5', unit: 'µg/m³', deviceClass: 'pm25', stateClass: 'measurement' }, + power: { name: 'Power', unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + temperature: { name: 'Temperature', unit: '°C', deviceClass: 'temperature', stateClass: 'measurement' }, + totalconsum: { name: 'Total consumption', unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }, + volt: { name: 'Voltage', unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }, +}; diff --git a/ts/integrations/broadlink/broadlink.discovery.ts b/ts/integrations/broadlink/broadlink.discovery.ts new file mode 100644 index 0000000..bace778 --- /dev/null +++ b/ts/integrations/broadlink/broadlink.discovery.ts @@ -0,0 +1,242 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { + broadlinkDefaultPort, + broadlinkDefaultTimeoutSeconds, + broadlinkDomain, + broadlinkMacPrefixes, + broadlinkSupportedTypes, +} from './broadlink.constants.js'; +import type { + IBroadlinkCandidateMetadata, + IBroadlinkDhcpRecord, + IBroadlinkLocalDiscoveryRecord, + IBroadlinkManualEntry, +} from './broadlink.types.js'; + +export class BroadlinkDhcpMatcher implements IDiscoveryMatcher { + public id = 'broadlink-dhcp-match'; + public source = 'dhcp' as const; + public description = 'Recognize Broadlink DHCP leases using Home Assistant manifest MAC prefixes.'; + + public async matches(recordArg: IBroadlinkDhcpRecord): Promise { + const metadata = recordArg.metadata || {}; + const host = recordArg.host || recordArg.ipAddress || recordArg.address || recordArg.ip; + const hostname = recordArg.hostname || recordArg.hostName; + const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || stringValue(metadata.macAddress)); + const model = recordArg.model || stringValue(metadata.model) || stringValue(metadata.deviceType); + const text = [recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const macMatched = isBroadlinkMac(macAddress); + const textMatched = text.includes('broadlink'); + const matched = recordArg.integrationDomain === broadlinkDomain || metadata.broadlink === true || macMatched || textMatched; + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'DHCP record does not match Broadlink metadata.' }; + } + + const id = macAddress || stringValue(metadata.deviceId) || hostname || host; + return { + matched: true, + confidence: macMatched && host ? 'certain' : host ? 'high' : 'medium', + reason: macMatched ? 'DHCP MAC prefix matches Home Assistant Broadlink manifest rules.' : 'DHCP metadata identifies a Broadlink device.', + normalizedDeviceId: id, + candidate: { + source: 'dhcp', + integrationDomain: broadlinkDomain, + id, + host, + port: broadlinkDefaultPort, + name: hostname || model || 'Broadlink device', + manufacturer: recordArg.manufacturer || 'Broadlink', + model, + macAddress, + metadata: { + ...metadata, + broadlink: true, + hostname, + macMatched, + discoveryProtocol: 'dhcp', + timeout: broadlinkDefaultTimeoutSeconds, + }, + }, + metadata: { macMatched, model }, + }; + } +} + +export class BroadlinkLocalDiscoveryMatcher implements IDiscoveryMatcher { + public id = 'broadlink-local-discovery-match'; + public source = 'custom' as const; + public description = 'Recognize normalized Broadlink UDP hello responses supplied by a host discovery layer.'; + + public async matches(recordArg: IBroadlinkLocalDiscoveryRecord): Promise { + const metadata = recordArg.metadata || {}; + const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || stringValue(metadata.macAddress)); + const deviceType = recordArg.type || stringValue(metadata.deviceType); + const supportedType = isSupportedType(deviceType); + const matched = Boolean(recordArg.host && (metadata.broadlink === true || macAddress || supportedType || recordArg.devtype || recordArg.model)); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Local discovery record is not a Broadlink hello response.' }; + } + + const id = macAddress || recordArg.host; + return { + matched: true, + confidence: recordArg.host && macAddress && supportedType ? 'certain' : recordArg.host ? 'high' : 'medium', + reason: supportedType ? 'Broadlink hello response contains a supported device type.' : 'Broadlink hello response contains local host or MAC data.', + normalizedDeviceId: id, + candidate: { + source: 'custom', + integrationDomain: broadlinkDomain, + id, + host: recordArg.host, + port: recordArg.port || broadlinkDefaultPort, + name: recordArg.name || recordArg.model || 'Broadlink device', + manufacturer: recordArg.manufacturer || 'Broadlink', + model: recordArg.model || deviceType, + macAddress, + metadata: { + ...metadata, + broadlink: true, + discoveryProtocol: 'broadlink-hello', + deviceType, + devtype: recordArg.devtype, + isLocked: recordArg.isLocked ?? recordArg.locked, + timeout: broadlinkDefaultTimeoutSeconds, + } satisfies IBroadlinkCandidateMetadata, + }, + }; + } +} + +export class BroadlinkManualMatcher implements IDiscoveryMatcher { + public id = 'broadlink-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Broadlink host, snapshot, learned-code, and custom-switch setup entries.'; + + public async matches(inputArg: IBroadlinkManualEntry): Promise { + const metadata = inputArg.metadata || {}; + const macAddress = normalizeMac(inputArg.macAddress || inputArg.mac || stringValue(metadata.macAddress)); + const deviceType = inputArg.type || stringValue(metadata.deviceType); + const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, deviceType, metadata.brand, metadata.manufacturer] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const snapshot = inputArg.snapshot || metadata.snapshot as IBroadlinkCandidateMetadata['snapshot']; + const hasManualData = Boolean(inputArg.host || inputArg.device || inputArg.devices?.length || inputArg.codes || inputArg.switches?.length || inputArg.state || inputArg.sensors); + const matched = inputArg.integrationDomain === broadlinkDomain + || metadata.broadlink === true + || Boolean(snapshot) + || Boolean(macAddress && isBroadlinkMac(macAddress)) + || isSupportedType(deviceType) + || text.includes('broadlink') + || hasManualData && Boolean(inputArg.host || macAddress || deviceType || inputArg.codes || inputArg.switches?.length); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Broadlink setup data.' }; + } + + const id = inputArg.id || inputArg.deviceId || macAddress || inputArg.host; + return { + matched: true, + confidence: snapshot ? 'certain' : inputArg.host && (macAddress || deviceType) ? 'high' : inputArg.host ? 'medium' : 'low', + reason: snapshot ? 'Manual entry includes a Broadlink snapshot.' : 'Manual entry can start Broadlink setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: broadlinkDomain, + id, + host: inputArg.host, + port: inputArg.port || broadlinkDefaultPort, + name: inputArg.name || inputArg.model || 'Broadlink device', + manufacturer: inputArg.manufacturer || 'Broadlink', + model: inputArg.model || deviceType, + macAddress, + metadata: { + ...metadata, + broadlink: true, + manual: true, + deviceType, + devtype: inputArg.devtype, + timeout: inputArg.timeout || broadlinkDefaultTimeoutSeconds, + snapshot, + device: inputArg.device, + devices: inputArg.devices, + state: inputArg.state, + sensors: inputArg.sensors, + codesConfigured: Boolean(inputArg.codes), + customSwitchesConfigured: Boolean(inputArg.switches?.length), + } satisfies IBroadlinkCandidateMetadata, + }, + metadata: { snapshotConfigured: Boolean(snapshot), codesConfigured: Boolean(inputArg.codes) }, + }; + } +} + +export class BroadlinkCandidateValidator implements IDiscoveryValidator { + public id = 'broadlink-candidate-validator'; + public description = 'Validate Broadlink candidates from DHCP, local hello, and manual setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const macAddress = normalizeMac(candidateArg.macAddress || stringValue(metadata.macAddress)); + const deviceType = stringValue(metadata.deviceType) || candidateArg.model; + const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const macMatched = isBroadlinkMac(macAddress); + const snapshotConfigured = metadata.snapshot !== undefined; + const matched = candidateArg.integrationDomain === broadlinkDomain + || metadata.broadlink === true + || snapshotConfigured + || macMatched + || isSupportedType(deviceType) + || text.includes('broadlink') + || candidateArg.source === 'manual' && Boolean(candidateArg.host); + + return { + matched, + confidence: matched && (macMatched || snapshotConfigured || candidateArg.integrationDomain === broadlinkDomain) && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Broadlink metadata or manual setup data.' : 'Candidate is not Broadlink.', + candidate: matched ? { ...candidateArg, port: candidateArg.port || broadlinkDefaultPort } : undefined, + normalizedDeviceId: macAddress || candidateArg.id || candidateArg.host, + metadata: matched ? { macMatched, snapshotConfigured, deviceType } : undefined, + }; + } +} + +export const createBroadlinkDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: broadlinkDomain, displayName: 'Broadlink' }) + .addMatcher(new BroadlinkDhcpMatcher()) + .addMatcher(new BroadlinkLocalDiscoveryMatcher()) + .addMatcher(new BroadlinkManualMatcher()) + .addValidator(new BroadlinkCandidateValidator()); +}; + +export const normalizeBroadlinkMac = (valueArg?: string): string | undefined => normalizeMac(valueArg); + +const 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(':') : undefined; +}; + +const isBroadlinkMac = (valueArg?: string): boolean => { + const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + return broadlinkMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg)); +}; + +const isSupportedType = (valueArg?: string): boolean => { + return Boolean(valueArg && broadlinkSupportedTypes.has(valueArg.toUpperCase())); +}; + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; diff --git a/ts/integrations/broadlink/broadlink.mapper.ts b/ts/integrations/broadlink/broadlink.mapper.ts new file mode 100644 index 0000000..d31209a --- /dev/null +++ b/ts/integrations/broadlink/broadlink.mapper.ts @@ -0,0 +1,882 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import { + broadlinkDefaultDelaySeconds, + broadlinkDefaultPort, + broadlinkDomain, + broadlinkRemoteTypes, + broadlinkRfRemoteTypes, + broadlinkSensorDescriptions, + broadlinkSwitchTypes, +} from './broadlink.constants.js'; +import { + broadlinkIrPacketFromTimings, + broadlinkPacketFromBase64, + broadlinkPacketFromBytes, + broadlinkPacketFromHex, + broadlinkRfPacketFromTimings, +} from './broadlink.packet.js'; +import type { + IBroadlinkCommand, + IBroadlinkConfig, + IBroadlinkCustomSwitchConfig, + IBroadlinkDevice, + IBroadlinkEntityDescriptor, + IBroadlinkEvent, + IBroadlinkPacket, + IBroadlinkSnapshot, + TBroadlinkCodeStore, + TBroadlinkDeviceType, +} from './broadlink.types.js'; + +interface IBroadlinkSwitchDefinition { + id: string; + name: string; + key?: string; + slot?: number; + method: 'send_data' | 'set_power' | 'set_state'; + custom?: IBroadlinkCustomSwitchConfig; +} + +export class BroadlinkMapper { + public static toSnapshot(configArg: IBroadlinkConfig, connectedArg?: boolean, eventsArg: IBroadlinkEvent[] = []): IBroadlinkSnapshot { + const source = configArg.snapshot; + const primaryDevice = this.primaryDevice(configArg, source); + const devices = this.uniqueDevices([ + ...(source?.devices || []), + ...(configArg.devices || []), + ...(primaryDevice ? [primaryDevice] : []), + ...this.devicesFromManualEntries(configArg), + ]); + + return { + connected: connectedArg ?? source?.connected ?? configArg.connected ?? devices.some((deviceArg) => deviceArg.available === true || deviceArg.online === true) ?? false, + host: configArg.host || source?.host, + port: configArg.port || source?.port || broadlinkDefaultPort, + devices, + entities: [...(source?.entities || []), ...(configArg.entities || [])], + events: [...(source?.events || []), ...eventsArg], + metadata: { + ...source?.metadata, + ...configArg.metadata, + liveUdpAuthImplemented: false, + }, + }; + } + + public static toDevices(snapshotArg: IBroadlinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + return snapshotArg.devices.map((deviceArg) => this.toDevice(deviceArg, snapshotArg)); + } + + public static toEntities(snapshotArg: IBroadlinkSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const seen = new Set(); + const addEntity = (entityArg: IIntegrationEntity | undefined) => { + if (!entityArg || seen.has(entityArg.id)) { + return; + } + seen.add(entityArg.id); + entities.push(entityArg); + }; + + for (const descriptor of snapshotArg.entities) { + addEntity(this.entityFromDescriptor(snapshotArg, descriptor, usedIds)); + } + + for (const device of snapshotArg.devices) { + const deviceId = this.deviceId(device); + const baseName = this.deviceName(device); + if (this.isRemote(device)) { + addEntity(this.entity('remote' as TEntityPlatform, baseName, deviceId, this.uniqueId('remote', device), this.remoteState(device), usedIds, { + ...this.baseAttributes(device), + supportedFeatures: ['learn_command', 'delete_command', 'send_command'], + supportsRf: this.supportsRf(device), + codeDevices: Object.keys(this.codesForDevice(device)), + writable: true, + }, this.available(device))); + } + + for (const switchDef of this.switchDefinitions(device)) { + addEntity(this.entity('switch', switchDef.name, deviceId, `${this.uniqueId('switch', device)}_${this.slug(switchDef.id)}`, this.switchState(device, switchDef), usedIds, { + ...this.baseAttributes(device), + broadlinkSwitchId: switchDef.id, + broadlinkSwitchKey: switchDef.key, + broadlinkSlot: switchDef.slot, + broadlinkMethod: switchDef.method, + commandOn: switchDef.custom?.commandOn || switchDef.custom?.command_on, + commandOff: switchDef.custom?.commandOff || switchDef.custom?.command_off, + assumedState: switchDef.custom ? true : false, + writable: true, + ...switchDef.custom?.metadata, + }, switchDef.custom?.available !== false && this.available(device), switchDef.custom?.entityId)); + } + + for (const sensor of this.sensorProperties(device)) { + addEntity(this.sensorEntity(device, sensor, usedIds)); + } + } + + return entities; + } + + public static toIntegrationEvent(eventArg: IBroadlinkEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' ? 'error' : eventArg.type === 'snapshot_refreshed' ? 'state_changed' : 'state_changed', + integrationDomain: broadlinkDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static commandForService(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + if (requestArg.domain === broadlinkDomain && ['send_packet', 'send_data'].includes(requestArg.service)) { + return this.packetServiceCommand(snapshotArg, requestArg, requestArg.service === 'send_packet' ? 'send_packet' : 'send_data'); + } + + if ((requestArg.domain === broadlinkDomain && requestArg.service === 'send_ir') || (requestArg.domain === 'infrared' && requestArg.service === 'send_command')) { + return this.irServiceCommand(snapshotArg, requestArg); + } + + if ((requestArg.domain === broadlinkDomain && requestArg.service === 'send_rf') || (requestArg.domain === 'radio_frequency' && requestArg.service === 'send_command')) { + return this.rfServiceCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === 'remote' && requestArg.service === 'send_command') { + return this.remoteSendCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === 'remote' && requestArg.service === 'learn_command') { + return this.learnCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === 'remote' && requestArg.service === 'delete_command') { + return this.deleteCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === 'remote' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) { + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + if (!target) { + return undefined; + } + return { + type: requestArg.service === 'turn_on' ? 'remote_turn_on' : 'remote_turn_off', + method: 'local_remote_enabled', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + transmitted: false, + }; + } + + if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) { + return this.switchCommand(snapshotArg, requestArg); + } + + return undefined; + } + + public static deviceId(deviceArg: IBroadlinkDevice): string { + return `broadlink.device.${this.slug(deviceArg.id || this.mac(deviceArg) || deviceArg.host || deviceArg.name || deviceArg.type || 'configured')}`; + } + + public static slug(valueArg: unknown): string { + const slug = String(valueArg || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return slug || 'unknown'; + } + + private static toDevice(deviceArg: IBroadlinkDevice, snapshotArg: IBroadlinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition { + const updatedAt = deviceArg.updatedAt || new Date().toISOString(); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'availability', value: this.available(deviceArg) ? 'online' : 'offline', updatedAt }, + ]; + + if (this.isRemote(deviceArg)) { + features.push({ id: 'remote', capability: 'switch', name: 'Remote', readable: true, writable: true }); + this.pushDeviceState(state, 'remote', this.remoteState(deviceArg), updatedAt); + } + + for (const switchDef of this.switchDefinitions(deviceArg)) { + features.push({ id: `switch_${this.slug(switchDef.id)}`, capability: 'switch', name: switchDef.name, readable: true, writable: true }); + this.pushDeviceState(state, `switch_${this.slug(switchDef.id)}`, this.switchState(deviceArg, switchDef) === 'on', updatedAt); + } + + for (const sensor of this.sensorProperties(deviceArg)) { + const capability = sensor.key === 'power' || sensor.key === 'totalconsum' ? 'energy' : 'sensor'; + features.push({ id: sensor.key, capability, name: sensor.description.name, readable: true, writable: false, unit: sensor.description.unit }); + this.pushDeviceState(state, sensor.key, this.deviceStateValue(sensor.value), updatedAt); + } + + return { + id: this.deviceId(deviceArg), + integrationDomain: broadlinkDomain, + name: this.deviceName(deviceArg), + protocol: 'unknown', + manufacturer: deviceArg.manufacturer || 'Broadlink', + model: deviceArg.model || deviceArg.type, + online: snapshotArg.connected && this.available(deviceArg), + features: this.uniqueFeatures(features), + state, + metadata: this.cleanAttributes({ + ...deviceArg.metadata, + host: deviceArg.host || snapshotArg.host, + port: deviceArg.port || snapshotArg.port || broadlinkDefaultPort, + macAddress: this.mac(deviceArg), + type: deviceArg.type, + devtype: deviceArg.devtype, + firmwareVersion: deviceArg.firmwareVersion, + locked: deviceArg.locked ?? deviceArg.isLocked, + authorized: deviceArg.authorized, + liveUdpAuthImplemented: false, + }), + }; + } + + private static packetServiceCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest, typeArg: 'send_data' | 'send_packet'): IBroadlinkCommand | undefined { + const packet = this.packetFromData(requestArg.data); + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + if (!packet || !target) { + return undefined; + } + return { + type: typeArg, + method: 'send_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + packet, + packets: [packet], + numRepeats: this.positiveInteger(requestArg.data?.num_repeats ?? requestArg.data?.numRepeats) || 1, + delaySecs: this.numberData(requestArg.data, 'delay_secs') ?? this.numberData(requestArg.data, 'delaySecs') ?? broadlinkDefaultDelaySeconds, + }; + } + + private static irServiceCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const packet = this.packetFromData(requestArg.data) || this.irPacketFromData(requestArg.data); + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + if (!packet || !target) { + return undefined; + } + return { + type: 'send_ir', + method: 'send_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + packet, + packets: [packet], + commandType: 'ir', + }; + } + + private static rfServiceCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const packet = this.packetFromData(requestArg.data) || this.rfPacketFromData(requestArg.data); + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRfRemoteEntity(snapshotArg); + if (!packet || !target) { + return undefined; + } + return { + type: 'send_rf', + method: 'send_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + packet, + packets: [packet], + commandType: 'rf', + }; + } + + private static remoteSendCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + const device = target ? this.deviceById(snapshotArg, target.deviceId) : undefined; + const remoteDevice = this.stringValue(requestArg.data?.device || requestArg.data?.remoteDevice); + const commandNames = this.commandList(requestArg.data?.command); + if (!target || !commandNames.length) { + return undefined; + } + const packets = this.packetsForCommands(snapshotArg, device, commandNames, remoteDevice); + if (!packets) { + return undefined; + } + return { + type: 'send_data', + method: 'send_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + packet: packets[0], + packets, + commandNames, + remoteDevice, + numRepeats: this.positiveInteger(requestArg.data?.num_repeats ?? requestArg.data?.numRepeats) || 1, + delaySecs: this.numberData(requestArg.data, 'delay_secs') ?? this.numberData(requestArg.data, 'delaySecs') ?? broadlinkDefaultDelaySeconds, + }; + } + + private static learnCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + const device = target ? this.deviceById(snapshotArg, target.deviceId) : undefined; + const commandType = this.stringValue(requestArg.data?.command_type || requestArg.data?.commandType) === 'rf' ? 'rf' : 'ir'; + const remoteDevice = this.stringValue(requestArg.data?.device || requestArg.data?.remoteDevice); + const commandNames = this.commandList(requestArg.data?.command); + if (!target || !remoteDevice || !commandNames.length) { + return undefined; + } + if (commandType === 'rf' && device && !this.supportsRf(device)) { + return undefined; + } + return { + type: commandType === 'rf' ? 'learn_rf' : 'learn_ir', + method: commandType === 'rf' ? 'sweep_frequency/find_rf_packet/check_data' : 'enter_learning/check_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + commandNames, + remoteDevice, + commandType, + alternative: this.booleanValue(requestArg.data?.alternative) || false, + metadata: { + learningTimeoutSeconds: 30, + requiresLiveTransport: true, + }, + }; + } + + private static deleteCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg) || this.firstRemoteEntity(snapshotArg); + const remoteDevice = this.stringValue(requestArg.data?.device || requestArg.data?.remoteDevice); + const commandNames = this.commandList(requestArg.data?.command); + if (!target || !remoteDevice || !commandNames.length) { + return undefined; + } + return { + type: 'delete_command', + method: 'local_delete_code', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + commandNames, + remoteDevice, + transmitted: false, + }; + } + + private static switchCommand(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IBroadlinkCommand | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + const device = target ? this.deviceById(snapshotArg, target.deviceId) : undefined; + if (!target || !device) { + return undefined; + } + const switchId = this.stringValue(target.attributes?.broadlinkSwitchId); + const switchDef = this.switchDefinitions(device).find((definitionArg) => definitionArg.id === switchId); + if (!switchDef) { + return undefined; + } + const turnOn = requestArg.service === 'turn_on'; + if (switchDef.method === 'send_data') { + const packetSource = turnOn ? switchDef.custom?.commandOn || switchDef.custom?.command_on : switchDef.custom?.commandOff || switchDef.custom?.command_off; + if (!packetSource) { + return undefined; + } + const packet = broadlinkPacketFromBase64(packetSource); + return { + type: 'send_data', + method: 'send_data', + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + packet, + packets: [packet], + }; + } + const payload = switchDef.method === 'set_state' + ? { [switchDef.key || 'pwr']: turnOn } + : switchDef.slot ? { slot: switchDef.slot, pwr: turnOn } : { pwr: turnOn }; + return { + type: switchDef.method, + method: switchDef.method, + service: requestArg.service, + deviceId: target.deviceId, + entityId: target.id, + target: requestArg.target, + data: requestArg.data, + payload, + }; + } + + private static entityFromDescriptor(snapshotArg: IBroadlinkSnapshot, entityArg: IBroadlinkEntityDescriptor, usedIdsArg: Map): IIntegrationEntity { + const platform = this.corePlatform(entityArg.platform || 'sensor'); + const name = entityArg.name || entityArg.entityId || entityArg.id || 'Broadlink entity'; + return this.entity(platform, name, entityArg.deviceId || this.firstDeviceId(snapshotArg), entityArg.uniqueId || `broadlink_${this.slug(entityArg.id || entityArg.entityId || name)}`, entityArg.state ?? null, usedIdsArg, entityArg.attributes, entityArg.available !== false, entityArg.entityId || entityArg.id); + } + + private static sensorEntity(deviceArg: IBroadlinkDevice, sensorArg: ReturnType[number], usedIdsArg: Map): IIntegrationEntity { + const baseName = this.deviceName(deviceArg); + return this.entity('sensor', `${baseName} ${sensorArg.description.name}`, this.deviceId(deviceArg), `${this.uniqueId('sensor', deviceArg)}_${this.slug(sensorArg.key)}`, sensorArg.value ?? null, usedIdsArg, { + ...this.baseAttributes(deviceArg), + broadlinkSensorKey: sensorArg.key, + unitOfMeasurement: sensorArg.description.unit, + deviceClass: sensorArg.description.deviceClass, + stateClass: sensorArg.description.stateClass, + }, this.available(deviceArg)); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg?: Record, availableArg = true, requestedEntityIdArg?: string): IIntegrationEntity { + return { + id: requestedEntityIdArg || this.uniqueEntityId(platformArg, nameArg, usedIdsArg), + uniqueId: uniqueIdArg, + integrationDomain: broadlinkDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: this.entityState(stateArg, platformArg), + attributes: this.cleanAttributes(attributesArg || {}), + available: availableArg, + }; + } + + private static primaryDevice(configArg: IBroadlinkConfig, sourceArg?: IBroadlinkSnapshot): IBroadlinkDevice | undefined { + if (!configArg.host && !configArg.name && !configArg.macAddress && !configArg.mac && !configArg.type && !configArg.state && !configArg.sensors && !configArg.codes && !configArg.switches?.length) { + return undefined; + } + return { + id: configArg.deviceId || configArg.macAddress || configArg.mac || configArg.host || 'configured', + host: configArg.host || sourceArg?.host, + port: configArg.port || sourceArg?.port || broadlinkDefaultPort, + macAddress: configArg.macAddress || configArg.mac, + type: configArg.type, + devtype: configArg.devtype, + name: configArg.name, + model: configArg.model || configArg.type, + manufacturer: configArg.manufacturer || 'Broadlink', + firmwareVersion: configArg.firmwareVersion, + locked: configArg.locked ?? configArg.isLocked, + authorized: configArg.authorized, + available: configArg.connected, + online: configArg.connected, + state: configArg.state, + sensors: configArg.sensors, + codes: configArg.codes, + flags: configArg.flags, + switches: configArg.switches, + metadata: configArg.metadata, + }; + } + + private static devicesFromManualEntries(configArg: IBroadlinkConfig): IBroadlinkDevice[] { + const devices: IBroadlinkDevice[] = []; + for (const entry of configArg.manualEntries || []) { + if (entry.snapshot?.devices) { + devices.push(...entry.snapshot.devices); + } + if (entry.devices?.length) { + devices.push(...entry.devices); + } + if (entry.device) { + devices.push(entry.device); + } + if (entry.host || entry.macAddress || entry.mac || entry.type || entry.state || entry.sensors || entry.codes || entry.switches?.length) { + devices.push({ + id: entry.deviceId || entry.id || entry.macAddress || entry.mac || entry.host, + host: entry.host, + port: entry.port || broadlinkDefaultPort, + macAddress: entry.macAddress || entry.mac, + type: entry.type, + devtype: entry.devtype, + name: entry.name, + model: entry.model || entry.type, + manufacturer: entry.manufacturer || 'Broadlink', + locked: entry.locked ?? entry.isLocked, + state: entry.state, + sensors: entry.sensors, + codes: entry.codes, + switches: entry.switches, + metadata: entry.metadata, + }); + } + } + return devices; + } + + private static uniqueDevices(devicesArg: IBroadlinkDevice[]): IBroadlinkDevice[] { + const seen = new Set(); + const devices: IBroadlinkDevice[] = []; + for (const device of devicesArg) { + const id = this.deviceId(device); + if (seen.has(id)) { + continue; + } + seen.add(id); + devices.push(device); + } + return devices; + } + + private static switchDefinitions(deviceArg: IBroadlinkDevice): IBroadlinkSwitchDefinition[] { + const type = this.deviceType(deviceArg); + const definitions: IBroadlinkSwitchDefinition[] = []; + if (['SP1', 'SP2', 'SP2S', 'SP3', 'SP3S'].includes(type)) { + definitions.push({ id: 'pwr', name: this.deviceName(deviceArg), key: 'pwr', method: 'set_power' }); + } else if (['SP4', 'SP4B'].includes(type)) { + definitions.push({ id: 'pwr', name: this.deviceName(deviceArg), key: 'pwr', method: 'set_state' }); + } else if (['MP1', 'MP1S'].includes(type)) { + for (const slot of [1, 2, 3, 4]) { + definitions.push({ id: `s${slot}`, name: `${this.deviceName(deviceArg)} S${slot}`, key: `s${slot}`, slot, method: 'set_power' }); + } + } else if (type === 'BG1') { + for (const slot of [1, 2]) { + definitions.push({ id: `pwr${slot}`, name: `${this.deviceName(deviceArg)} S${slot}`, key: `pwr${slot}`, slot, method: 'set_state' }); + } + } + for (const customSwitch of deviceArg.switches || []) { + definitions.push({ id: customSwitch.id || customSwitch.name, name: customSwitch.name, method: 'send_data', custom: customSwitch }); + } + return definitions; + } + + private static sensorProperties(deviceArg: IBroadlinkDevice): Array<{ key: string; value: unknown; description: typeof broadlinkSensorDescriptions[string] }> { + const state = { ...(deviceArg.state || {}), ...(deviceArg.sensors || {}) }; + const type = this.deviceType(deviceArg); + const properties: Array<{ key: string; value: unknown; description: typeof broadlinkSensorDescriptions[string] }> = []; + for (const [key, description] of Object.entries(broadlinkSensorDescriptions)) { + if (!(key in state)) { + continue; + } + const value = state[key]; + if ((type === 'RM4PRO' || type === 'RM4MINI') && (key === 'temperature' || key === 'humidity') && value === 0) { + continue; + } + properties.push({ key, value, description }); + } + return properties; + } + + private static packetsForCommands(snapshotArg: IBroadlinkSnapshot, deviceArg: IBroadlinkDevice | undefined, commandsArg: string[], remoteDeviceArg?: string): IBroadlinkPacket[] | undefined { + const packets: IBroadlinkPacket[] = []; + for (const command of commandsArg) { + if (command.startsWith('b64:')) { + packets.push(broadlinkPacketFromBase64(command)); + continue; + } + const code = this.lookupCode(snapshotArg, deviceArg, remoteDeviceArg, command); + if (!code) { + return undefined; + } + const selected = Array.isArray(code) ? code[this.toggleIndex(deviceArg, remoteDeviceArg, code.length)] : code; + packets.push(broadlinkPacketFromBase64(selected)); + } + return packets; + } + + private static lookupCode(snapshotArg: IBroadlinkSnapshot, deviceArg: IBroadlinkDevice | undefined, remoteDeviceArg: string | undefined, commandArg: string): string | string[] | undefined { + const stores: TBroadlinkCodeStore[] = []; + if (deviceArg?.codes) { + stores.push(deviceArg.codes); + } + for (const device of snapshotArg.devices) { + if (device.codes && device !== deviceArg) { + stores.push(device.codes); + } + } + for (const store of stores) { + if (remoteDeviceArg && store[remoteDeviceArg]?.[commandArg]) { + return store[remoteDeviceArg][commandArg]; + } + if (!remoteDeviceArg) { + for (const codes of Object.values(store)) { + if (codes[commandArg]) { + return codes[commandArg]; + } + } + } + } + return undefined; + } + + private static packetFromData(dataArg?: Record): IBroadlinkPacket | undefined { + const value = dataArg?.packet ?? dataArg?.code ?? dataArg?.base64 ?? dataArg?.rawData ?? dataArg?.data; + if (this.isRecord(value)) { + if (typeof value.base64 === 'string') { + return broadlinkPacketFromBase64(value.base64); + } + if (typeof value.hex === 'string') { + return broadlinkPacketFromHex(value.hex); + } + if (Array.isArray(value.bytes)) { + return broadlinkPacketFromBytes(value.bytes.filter((itemArg): itemArg is number => typeof itemArg === 'number')); + } + } + if (Array.isArray(value)) { + return broadlinkPacketFromBytes(value.filter((itemArg): itemArg is number => typeof itemArg === 'number')); + } + if (typeof dataArg?.hex === 'string') { + return broadlinkPacketFromHex(dataArg.hex); + } + if (typeof value === 'string' && value.trim()) { + const text = value.trim(); + return text.startsWith('hex:') || /^[0-9a-f\s:]+$/i.test(text) && text.replace(/[^0-9a-f]/gi, '').length % 2 === 0 + ? broadlinkPacketFromHex(text.replace(/^hex:/i, '')) + : broadlinkPacketFromBase64(text); + } + return undefined; + } + + private static irPacketFromData(dataArg?: Record): IBroadlinkPacket | undefined { + const timings = this.timingsFromData(dataArg); + return timings?.length ? broadlinkIrPacketFromTimings(timings) : undefined; + } + + private static rfPacketFromData(dataArg?: Record): IBroadlinkPacket | undefined { + const timings = this.timingsFromData(dataArg); + const frequency = this.numberData(dataArg, 'frequency') ?? this.numberData(dataArg, 'carrier_frequency') ?? this.numberData(dataArg, 'carrierFrequency'); + if (!timings?.length || frequency === undefined) { + return undefined; + } + try { + return broadlinkRfPacketFromTimings({ + frequency, + timings, + repeatCount: this.positiveInteger(dataArg?.repeat_count ?? dataArg?.repeatCount) || 0, + }); + } catch { + return undefined; + } + } + + private static timingsFromData(dataArg?: Record): number[] | undefined { + const command = this.isRecord(dataArg?.command) ? dataArg.command : undefined; + const value = dataArg?.rawTimings ?? dataArg?.raw_timings ?? dataArg?.timings ?? dataArg?.pulses ?? command?.rawTimings ?? command?.raw_timings; + return Array.isArray(value) ? value.filter((itemArg): itemArg is number => typeof itemArg === 'number' && Number.isFinite(itemArg)) : undefined; + } + + private static findTargetEntity(snapshotArg: IBroadlinkSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entityId = requestArg.target.entityId || this.stringValue(requestArg.data?.entity_id || requestArg.data?.entityId); + const deviceId = requestArg.target.deviceId || this.stringValue(requestArg.data?.device_id || requestArg.data?.deviceId); + const entities = this.toEntities(snapshotArg); + if (entityId) { + return entities.find((entityArg) => entityArg.id === entityId || entityArg.uniqueId === entityId); + } + if (deviceId) { + return entities.find((entityArg) => entityArg.deviceId === deviceId || entityArg.uniqueId === deviceId); + } + return undefined; + } + + private static firstRemoteEntity(snapshotArg: IBroadlinkSnapshot): IIntegrationEntity | undefined { + return this.toEntities(snapshotArg).find((entityArg) => entityArg.platform === ('remote' as TEntityPlatform)); + } + + private static firstRfRemoteEntity(snapshotArg: IBroadlinkSnapshot): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + return entities.find((entityArg) => entityArg.platform === ('remote' as TEntityPlatform) && entityArg.attributes?.supportsRf === true); + } + + private static firstDeviceId(snapshotArg: IBroadlinkSnapshot): string { + return snapshotArg.devices[0] ? this.deviceId(snapshotArg.devices[0]) : 'broadlink.device.configured'; + } + + private static deviceById(snapshotArg: IBroadlinkSnapshot, deviceIdArg: string): IBroadlinkDevice | undefined { + return snapshotArg.devices.find((deviceArg) => this.deviceId(deviceArg) === deviceIdArg || deviceArg.id === deviceIdArg); + } + + private static isRemote(deviceArg: IBroadlinkDevice): boolean { + return broadlinkRemoteTypes.has(this.deviceType(deviceArg)) || Boolean(deviceArg.codes) || Boolean(deviceArg.switches?.length); + } + + private static supportsRf(deviceArg: IBroadlinkDevice): boolean { + return deviceArg.supportsRf === true || broadlinkRfRemoteTypes.has(this.deviceType(deviceArg)); + } + + private static available(deviceArg: IBroadlinkDevice): boolean { + return deviceArg.available !== false && deviceArg.online !== false; + } + + private static remoteState(deviceArg: IBroadlinkDevice): string { + const state = this.stringValue(deviceArg.state?.remoteState || deviceArg.state?.remote); + if (state === 'off') { + return 'off'; + } + return 'on'; + } + + private static switchState(deviceArg: IBroadlinkDevice, switchDefArg: IBroadlinkSwitchDefinition): string { + const customState = switchDefArg.custom?.state; + const state = deviceArg.state || {}; + const value = customState ?? (switchDefArg.key ? state[switchDefArg.key] : undefined) ?? state.pwr ?? state.power; + return this.onState(value) ? 'on' : 'off'; + } + + private static onState(valueArg: unknown): boolean { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg !== 0; + } + return ['on', 'true', '1', 'open'].includes(String(valueArg || '').toLowerCase()); + } + + private static codesForDevice(deviceArg: IBroadlinkDevice): TBroadlinkCodeStore { + return deviceArg.codes || {}; + } + + private static toggleIndex(deviceArg: IBroadlinkDevice | undefined, remoteDeviceArg: string | undefined, lengthArg: number): number { + const value = remoteDeviceArg ? deviceArg?.flags?.[remoteDeviceArg] : undefined; + return typeof value === 'number' && Number.isFinite(value) && lengthArg > 0 ? Math.abs(Math.floor(value)) % lengthArg : 0; + } + + private static baseAttributes(deviceArg: IBroadlinkDevice): Record { + return this.cleanAttributes({ + host: deviceArg.host, + port: deviceArg.port || broadlinkDefaultPort, + macAddress: this.mac(deviceArg), + type: deviceArg.type, + devtype: deviceArg.devtype, + model: deviceArg.model, + firmwareVersion: deviceArg.firmwareVersion, + locked: deviceArg.locked ?? deviceArg.isLocked, + authorized: deviceArg.authorized, + }); + } + + private static deviceName(deviceArg: IBroadlinkDevice): string { + return deviceArg.name || deviceArg.model || deviceArg.type || this.mac(deviceArg) || deviceArg.host || 'Broadlink device'; + } + + private static uniqueId(platformArg: string, deviceArg: IBroadlinkDevice): string { + return `broadlink_${platformArg}_${this.slug(deviceArg.id || this.mac(deviceArg) || deviceArg.host || this.deviceName(deviceArg))}`; + } + + private static uniqueEntityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg)}`; + const count = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, count + 1); + return count === 0 ? base : `${base}_${count + 1}`; + } + + private static deviceType(deviceArg: IBroadlinkDevice): TBroadlinkDeviceType { + return String(deviceArg.type || deviceArg.model || '').toUpperCase(); + } + + private static mac(deviceArg: IBroadlinkDevice): string | undefined { + const value = deviceArg.macAddress || deviceArg.mac; + if (!value) { + return undefined; + } + const compact = value.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : value; + } + + private static corePlatform(valueArg: unknown): TEntityPlatform { + const platform = String(valueArg || 'sensor'); + const supported = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update', 'remote']; + return supported.includes(platform) ? platform as TEntityPlatform : 'sensor'; + } + + private static entityState(valueArg: unknown, platformArg: TEntityPlatform): unknown { + if (platformArg === 'switch' || platformArg === 'binary_sensor' || platformArg === 'light') { + return this.onState(valueArg) ? 'on' : 'off'; + } + return valueArg; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (valueArg === undefined) { + return null; + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + return this.isRecord(valueArg) ? valueArg : String(valueArg); + } + + private static pushDeviceState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void { + if (valueArg === undefined) { + return; + } + stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static 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 cleanAttributes(valueArg: Record): Record { + return Object.fromEntries(Object.entries(valueArg).filter(([, value]) => value !== undefined)); + } + + private static commandList(valueArg: unknown): string[] { + if (Array.isArray(valueArg)) { + return valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && itemArg.trim().length > 0).map((itemArg) => itemArg.trim()); + } + return typeof valueArg === 'string' && valueArg.trim() ? [valueArg.trim()] : []; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static numberData(dataArg: Record | undefined, keyArg: string): number | undefined { + const value = dataArg?.[keyArg]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private static positiveInteger(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) { + return Math.floor(valueArg); + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const value = Number(valueArg); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; + } + return undefined; + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/broadlink/broadlink.packet.ts b/ts/integrations/broadlink/broadlink.packet.ts new file mode 100644 index 0000000..bb9d4d7 --- /dev/null +++ b/ts/integrations/broadlink/broadlink.packet.ts @@ -0,0 +1,90 @@ +import { broadlinkIrTickUs } from './broadlink.constants.js'; +import type { IBroadlinkPacket, TBroadlinkPacketKind } from './broadlink.types.js'; + +const rf433Range = [433_050_000, 434_790_000] as const; +const rf315Range = [314_950_000, 315_250_000] as const; + +export const broadlinkPacketFromBase64 = (valueArg: string, sourceArg: IBroadlinkPacket['source'] = 'base64'): IBroadlinkPacket => { + const cleanValue = valueArg.startsWith('b64:') ? valueArg.slice(4) : valueArg; + const padded = cleanValue + '='.repeat((4 - cleanValue.length % 4) % 4); + return broadlinkPacketFromBytes([...Buffer.from(padded, 'base64')], sourceArg); +}; + +export const broadlinkPacketFromHex = (valueArg: string): IBroadlinkPacket => { + const cleanValue = valueArg.replace(/[^0-9a-f]/gi, ''); + return broadlinkPacketFromBytes([...Buffer.from(cleanValue, 'hex')], 'hex'); +}; + +export const broadlinkIrPacketFromTimings = (timingsArg: number[]): IBroadlinkPacket => { + const bytes = [0x26, 0x00, 0x00, 0x00]; + appendBroadlinkPulses(bytes, timingsArg, Math.floor); + setPayloadLength(bytes); + return broadlinkPacketFromBytes(bytes, 'ir_timings'); +}; + +export const broadlinkRfPacketFromTimings = (optionsArg: { frequency: number; timings: number[]; repeatCount?: number }): IBroadlinkPacket => { + const typeByte = broadlinkRfTypeByte(optionsArg.frequency); + const bytes = [typeByte, optionsArg.repeatCount || 0, 0x00, 0x00]; + appendBroadlinkPulses(bytes, optionsArg.timings, Math.round); + setPayloadLength(bytes); + return { ...broadlinkPacketFromBytes(bytes, 'rf_timings'), frequency: optionsArg.frequency, repeatCount: optionsArg.repeatCount || 0 }; +}; + +export const broadlinkRfTypeByte = (frequencyArg: number): number => { + if (frequencyArg >= rf433Range[0] && frequencyArg <= rf433Range[1]) { + return 0xb2; + } + if (frequencyArg >= rf315Range[0] && frequencyArg <= rf315Range[1]) { + return 0xb4; + } + throw new Error(`Broadlink RF frequency is not supported: ${frequencyArg}.`); +}; + +export const broadlinkPacketFromBytes = (bytesArg: number[], sourceArg?: IBroadlinkPacket['source']): IBroadlinkPacket => { + const normalized = bytesArg.map((byteArg) => byteArg & 0xff); + const buffer = Buffer.from(normalized); + const firstByte = normalized[0]; + return { + base64: buffer.toString('base64'), + hex: buffer.toString('hex'), + bytes: normalized, + byteLength: normalized.length, + kind: packetKind(firstByte), + firstByte, + source: sourceArg, + }; +}; + +const appendBroadlinkPulses = (bytesArg: number[], timingsArg: number[], roundArg: (valueArg: number) => number): void => { + for (const timing of timingsArg) { + const ticks = roundArg(Math.abs(timing) / broadlinkIrTickUs); + const div = Math.floor(ticks / 256); + const mod = ticks % 256; + if (div) { + bytesArg.push(0x00, div & 0xff); + } + bytesArg.push(mod & 0xff); + } +}; + +const setPayloadLength = (bytesArg: number[]): void => { + const payloadLength = bytesArg.length - 4; + bytesArg[2] = payloadLength & 0xff; + bytesArg[3] = payloadLength >> 8; +}; + +const packetKind = (firstByteArg: number | undefined): TBroadlinkPacketKind => { + if (firstByteArg === 0x26) { + return 'ir'; + } + if (firstByteArg === 0xb2 || firstByteArg === 0xd7) { + return 'rf433'; + } + if (firstByteArg === 0xb4) { + return 'rf315'; + } + if (firstByteArg === 0xb1 || firstByteArg === 0xb3) { + return 'rf'; + } + return 'unknown'; +}; diff --git a/ts/integrations/broadlink/broadlink.types.ts b/ts/integrations/broadlink/broadlink.types.ts index d604f75..6a02e6f 100644 --- a/ts/integrations/broadlink/broadlink.types.ts +++ b/ts/integrations/broadlink/broadlink.types.ts @@ -1,4 +1,250 @@ -export interface IHomeAssistantBroadlinkConfig { - // TODO: replace with the TypeScript-native config for broadlink. - [key: string]: unknown; +import type { IServiceCallRequest, IServiceCallResult, TEntityPlatform } from '../../core/types.js'; + +export type TBroadlinkDeviceType = + | 'A1' + | 'A2' + | 'BG1' + | 'HYS' + | 'LB1' + | 'LB2' + | 'MP1' + | 'MP1S' + | 'RM4MINI' + | 'RM4PRO' + | 'RMMINI' + | 'RMMINIB' + | 'RMPRO' + | 'SP1' + | 'SP2' + | 'SP2S' + | 'SP3' + | 'SP3S' + | 'SP4' + | 'SP4B' + | string; + +export type TBroadlinkCommandType = + | 'delete_command' + | 'learn_ir' + | 'learn_rf' + | 'remote_turn_off' + | 'remote_turn_on' + | 'send_data' + | 'send_ir' + | 'send_packet' + | 'send_rf' + | 'set_power' + | 'set_state'; + +export type TBroadlinkEventType = + | 'command_deleted' + | 'command_executed' + | 'command_failed' + | 'command_mapped' + | 'snapshot_refreshed'; + +export type TBroadlinkPacketKind = 'ir' | 'rf315' | 'rf433' | 'rf' | 'unknown'; + +export interface IBroadlinkPacket { + base64: string; + hex: string; + bytes: number[]; + byteLength: number; + kind: TBroadlinkPacketKind; + firstByte?: number; + frequency?: number; + repeatCount?: number; + source?: 'base64' | 'hex' | 'ir_timings' | 'rf_timings'; } + +export type TBroadlinkCodeValue = string | string[]; + +export type TBroadlinkCodeStore = Record>; + +export interface IBroadlinkCustomSwitchConfig { + id?: string; + name: string; + entityId?: string; + uniqueId?: string; + commandOn?: string; + commandOff?: string; + command_on?: string; + command_off?: string; + state?: boolean | string; + available?: boolean; + metadata?: Record; +} + +export interface IBroadlinkEntityDescriptor { + id?: string; + entityId?: string; + uniqueId?: string; + deviceId?: string; + platform?: TEntityPlatform | 'remote' | string; + name?: string; + state?: unknown; + attributes?: Record; + available?: boolean; +} + +export interface IBroadlinkDevice { + id?: string; + host?: string; + port?: number; + macAddress?: string; + mac?: string; + type?: TBroadlinkDeviceType; + devtype?: number; + name?: string; + model?: string; + manufacturer?: string; + firmwareVersion?: string | number; + locked?: boolean; + isLocked?: boolean; + authorized?: boolean; + available?: boolean; + online?: boolean; + state?: Record; + sensors?: Record; + codes?: TBroadlinkCodeStore; + flags?: Record; + switches?: IBroadlinkCustomSwitchConfig[]; + supportsRf?: boolean; + updatedAt?: string; + metadata?: Record; +} + +export interface IBroadlinkTransport { + execute(commandArg: IBroadlinkCommand): Promise; +} + +export interface IBroadlinkConfig { + host?: string; + port?: number; + timeout?: number; + timeoutMs?: number; + name?: string; + macAddress?: string; + mac?: string; + deviceId?: string; + type?: TBroadlinkDeviceType; + devtype?: number; + model?: string; + manufacturer?: string; + firmwareVersion?: string | number; + isLocked?: boolean; + locked?: boolean; + authorized?: boolean; + connected?: boolean; + state?: Record; + sensors?: Record; + codes?: TBroadlinkCodeStore; + flags?: Record; + switches?: IBroadlinkCustomSwitchConfig[]; + devices?: IBroadlinkDevice[]; + entities?: IBroadlinkEntityDescriptor[]; + manualEntries?: IBroadlinkManualEntry[]; + snapshot?: IBroadlinkSnapshot; + metadata?: Record; + commandExecutor?: (commandArg: IBroadlinkCommand) => Promise; + transport?: IBroadlinkTransport; +} + +export interface IBroadlinkSnapshot { + connected: boolean; + host?: string; + port?: number; + devices: IBroadlinkDevice[]; + entities: IBroadlinkEntityDescriptor[]; + events: IBroadlinkEvent[]; + metadata?: Record; +} + +export interface IBroadlinkCommand { + type: TBroadlinkCommandType; + method: string; + service: string; + deviceId?: string; + entityId?: string; + target?: IServiceCallRequest['target']; + data?: Record; + payload?: Record; + packet?: IBroadlinkPacket; + packets?: IBroadlinkPacket[]; + commandNames?: string[]; + remoteDevice?: string; + commandType?: 'ir' | 'rf'; + alternative?: boolean; + numRepeats?: number; + delaySecs?: number; + transmitted?: boolean; + metadata?: Record; +} + +export interface IBroadlinkCommandResult extends IServiceCallResult { + transmitted?: boolean; +} + +export interface IBroadlinkEvent { + type: TBroadlinkEventType; + command?: IBroadlinkCommand; + deviceId?: string; + entityId?: string; + data?: unknown; + timestamp: number; +} + +export interface IBroadlinkDhcpRecord { + ip?: string; + ipAddress?: string; + address?: string; + host?: string; + hostname?: string; + hostName?: string; + mac?: string; + macAddress?: string; + manufacturer?: string; + model?: string; + integrationDomain?: string; + metadata?: Record; +} + +export interface IBroadlinkLocalDiscoveryRecord { + host?: string; + port?: number; + mac?: string; + macAddress?: string; + type?: TBroadlinkDeviceType; + devtype?: number; + name?: string; + model?: string; + manufacturer?: string; + isLocked?: boolean; + locked?: boolean; + metadata?: Record; +} + +export interface IBroadlinkManualEntry extends IBroadlinkLocalDiscoveryRecord { + id?: string; + deviceId?: string; + timeout?: number; + state?: Record; + sensors?: Record; + codes?: TBroadlinkCodeStore; + switches?: IBroadlinkCustomSwitchConfig[]; + device?: IBroadlinkDevice; + devices?: IBroadlinkDevice[]; + snapshot?: IBroadlinkSnapshot; + integrationDomain?: string; +} + +export interface IBroadlinkCandidateMetadata extends Record { + broadlink?: boolean; + deviceType?: TBroadlinkDeviceType; + devtype?: number; + isLocked?: boolean; + timeout?: number; + snapshot?: IBroadlinkSnapshot; +} + +export type IHomeAssistantBroadlinkConfig = IBroadlinkConfig; diff --git a/ts/integrations/broadlink/index.ts b/ts/integrations/broadlink/index.ts index 12b6930..6d7d335 100644 --- a/ts/integrations/broadlink/index.ts +++ b/ts/integrations/broadlink/index.ts @@ -1,2 +1,8 @@ +export * from './broadlink.classes.client.js'; +export * from './broadlink.classes.configflow.js'; export * from './broadlink.classes.integration.js'; +export * from './broadlink.constants.js'; +export * from './broadlink.discovery.js'; +export * from './broadlink.mapper.js'; +export * from './broadlink.packet.js'; export * from './broadlink.types.js'; diff --git a/ts/integrations/dsmr/.generated-by-smarthome-exchange b/ts/integrations/dsmr/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/dsmr/.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/dsmr/dsmr.classes.client.ts b/ts/integrations/dsmr/dsmr.classes.client.ts new file mode 100644 index 0000000..0d8a4b6 --- /dev/null +++ b/ts/integrations/dsmr/dsmr.classes.client.ts @@ -0,0 +1,183 @@ +import * as plugins from '../../plugins.js'; +import { DsmrTelegramParser } from './dsmr.parser.js'; +import type { IDsmrConfig, IDsmrEvent, IDsmrRefreshResult, IDsmrSnapshot, IDsmrStatusSnapshot } from './dsmr.types.js'; +import { dsmrDefaultNetworkPort, dsmrDefaultTimeoutMs } from './dsmr.types.js'; + +type TDsmrEventHandler = (eventArg: IDsmrEvent) => void; + +export class DsmrClient { + private currentSnapshot?: IDsmrSnapshot; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IDsmrConfig) {} + + public async getSnapshot(): Promise { + if (!this.currentSnapshot) { + this.currentSnapshot = await this.initialSnapshot(); + } + return this.cloneSnapshot(this.currentSnapshot); + } + + public onEvent(handlerArg: TDsmrEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async refresh(): Promise { + try { + const provided = await this.providedTelegramSnapshot(); + if (provided) { + this.currentSnapshot = provided; + this.emit({ type: 'snapshot_refreshed', snapshot: this.cloneSnapshot(provided), timestamp: Date.now() }); + return { success: true, snapshot: this.cloneSnapshot(provided), data: { source: provided.source } }; + } + + if (this.canReadNetwork()) { + const telegram = await this.readNetworkTelegram(); + const snapshot = DsmrTelegramParser.parseTelegram(telegram, { config: this.config, source: 'network', connected: true }); + this.currentSnapshot = snapshot; + this.emit({ type: 'telegram_received', snapshot: this.cloneSnapshot(snapshot), timestamp: Date.now() }); + return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'network' } }; + } + + const connection = DsmrTelegramParser.connectionInfo(this.config); + const reason = connection.connectionType === 'serial' + ? 'Native DSMR serial reading requires a telegramProvider or snapshot; no serial transport dependency is configured.' + : 'No DSMR telegram source is configured. Provide a snapshot, telegram, telegramProvider, or enable liveRead for a network endpoint.'; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'refresh_failed', snapshot, error: reason, timestamp: Date.now() }); + return { success: false, snapshot, error: reason }; + } catch (errorArg) { + const snapshot = await this.getSnapshot().catch(() => DsmrTelegramParser.emptySnapshot(this.config)); + const error = errorArg instanceof Error ? errorArg.message : String(errorArg); + this.currentSnapshot = { ...snapshot, connected: false, updatedAt: new Date().toISOString() }; + this.emit({ type: 'refresh_failed', snapshot: this.cloneSnapshot(this.currentSnapshot), error, timestamp: Date.now() }); + return { success: false, snapshot: this.cloneSnapshot(this.currentSnapshot), error }; + } + } + + public async readNetworkTelegram(): Promise { + const connection = DsmrTelegramParser.connectionInfo(this.config); + if (connection.connectionType !== 'network' || !connection.host) { + throw new Error('DSMR network telegram reading requires config.host and network connection type.'); + } + const port = typeof connection.port === 'number' ? connection.port : dsmrDefaultNetworkPort; + const timeoutMs = this.config.timeoutMs || dsmrDefaultTimeoutMs; + return await new Promise((resolve, reject) => { + const socket = plugins.net.createConnection({ host: connection.host, port }); + let buffer = ''; + let settled = false; + const timeout = setTimeout(() => finish(new Error(`DSMR network telegram read timed out after ${timeoutMs}ms.`)), timeoutMs); + const finish = (errorArg?: Error, telegramArg?: string) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + reject(errorArg); + return; + } + resolve(telegramArg || ''); + }; + const processBuffer = () => { + const telegram = this.extractTelegram(buffer); + if (telegram) { + finish(undefined, telegram); + } + }; + socket.on('data', (dataArg) => { + buffer += dataArg.toString('utf8'); + processBuffer(); + }); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => { + processBuffer(); + if (!settled) { + finish(new Error('DSMR network connection closed before a complete telegram was received.')); + } + }); + }); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async initialSnapshot(): Promise { + if (this.config.snapshot) { + return DsmrTelegramParser.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), this.config); + } + if (this.config.status) { + return DsmrTelegramParser.snapshotFromStatus(this.config.status, this.config); + } + if (typeof this.config.telegram === 'string') { + return DsmrTelegramParser.parseTelegram(this.config.telegram, { config: this.config, source: 'telegram' }); + } + if (this.isSnapshot(this.config.telegram)) { + return DsmrTelegramParser.normalizeSnapshot(this.cloneSnapshot(this.config.telegram), this.config); + } + if (Array.isArray(this.config.telegrams) && this.config.telegrams.length) { + return DsmrTelegramParser.parseTelegram(this.config.telegrams[this.config.telegrams.length - 1], { config: this.config, source: 'telegram' }); + } + return DsmrTelegramParser.emptySnapshot(this.config, this.config.connected ?? false); + } + + private async providedTelegramSnapshot(): Promise { + if (this.config.telegramProvider) { + const provided = await this.config.telegramProvider(); + return this.snapshotFromProvided(provided); + } + if (this.config.snapshot || this.config.status || this.config.telegram || this.config.telegrams?.length) { + return await this.initialSnapshot(); + } + return undefined; + } + + private snapshotFromProvided(providedArg: string | IDsmrSnapshot | IDsmrStatusSnapshot | undefined): IDsmrSnapshot | undefined { + if (typeof providedArg === 'string') { + return DsmrTelegramParser.parseTelegram(providedArg, { config: this.config, source: 'telegram' }); + } + if (this.isSnapshot(providedArg)) { + return DsmrTelegramParser.normalizeSnapshot(this.cloneSnapshot(providedArg), this.config); + } + if (providedArg && typeof providedArg === 'object') { + return DsmrTelegramParser.snapshotFromStatus(providedArg as IDsmrStatusSnapshot, this.config); + } + return undefined; + } + + private canReadNetwork(): boolean { + const connection = DsmrTelegramParser.connectionInfo(this.config); + return this.config.liveRead === true && connection.connectionType === 'network' && Boolean(connection.host); + } + + private extractTelegram(bufferArg: string): string | undefined { + const start = bufferArg.indexOf('/'); + if (start < 0) { + return undefined; + } + const body = bufferArg.slice(start); + const checksumMatch = body.match(/![0-9a-fA-F]{0,4}/); + if (!checksumMatch || checksumMatch.index === undefined) { + return undefined; + } + return body.slice(0, checksumMatch.index + checksumMatch[0].length); + } + + private emit(eventArg: IDsmrEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private isSnapshot(valueArg: unknown): valueArg is IDsmrSnapshot { + return Boolean(valueArg && typeof valueArg === 'object' && 'meter' in valueArg && Array.isArray((valueArg as IDsmrSnapshot).sensors)); + } + + private cloneSnapshot(snapshotArg: T): T { + return JSON.parse(JSON.stringify(snapshotArg)) as T; + } +} diff --git a/ts/integrations/dsmr/dsmr.classes.configflow.ts b/ts/integrations/dsmr/dsmr.classes.configflow.ts new file mode 100644 index 0000000..c9fc17b --- /dev/null +++ b/ts/integrations/dsmr/dsmr.classes.configflow.ts @@ -0,0 +1,85 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { dsmrRfxtrxProtocol, dsmrVersions } from './dsmr.constants.js'; +import type { IDsmrConfig, TDsmrConnectionType } from './dsmr.types.js'; +import { dsmrDefaultNetworkPort } from './dsmr.types.js'; + +export class DsmrConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect DSMR smart meter', + description: 'Configure a known DSMR/P1 serial port or network-to-P1 endpoint. The flow validates configuration shape only and does not claim live hardware success.', + fields: [ + { name: 'connectionType', label: 'Connection type', type: 'select', required: true, options: [{ label: 'Serial', value: 'serial' }, { label: 'Network', value: 'network' }] }, + { name: 'serialPort', label: 'Serial port path', type: 'text' }, + { name: 'host', label: 'Network host', type: 'text' }, + { name: 'port', label: 'Network port', type: 'number' }, + { name: 'dsmrVersion', label: 'DSMR version', type: 'select', required: true, options: dsmrVersions.map((versionArg) => ({ label: versionArg, value: versionArg })) }, + { name: 'protocol', label: 'Protocol', type: 'select', required: false, options: [{ label: 'DSMR', value: 'dsmr_protocol' }, { label: 'RFXtrx DSMR', value: dsmrRfxtrxProtocol }] }, + { name: 'liveRead', label: 'Read telegrams from network on refresh', type: 'boolean' }, + ], + submit: async (valuesArg) => { + const connectionType = this.connectionType(valuesArg.connectionType) || this.connectionType(candidateArg.metadata?.connectionType) || (candidateArg.host ? 'network' : 'serial'); + const serialPort = this.stringValue(valuesArg.serialPort) || this.stringValue(candidateArg.metadata?.serialPort); + const host = this.stringValue(valuesArg.host) || candidateArg.host; + const port = this.numberValue(valuesArg.port) || candidateArg.port || dsmrDefaultNetworkPort; + const dsmrVersion = this.dsmrVersion(valuesArg.dsmrVersion) || this.dsmrVersion(candidateArg.metadata?.dsmrVersion) || this.dsmrVersion(candidateArg.metadata?.dsmr_version) || '2.2'; + const protocol = this.protocol(valuesArg.protocol) || this.protocol(candidateArg.metadata?.protocol) || 'dsmr_protocol'; + + if (connectionType === 'network' && (!host || !port)) { + return { kind: 'error', title: 'DSMR configuration failed', error: 'Network DSMR setup requires host and port.' }; + } + if (connectionType === 'serial' && !serialPort) { + return { kind: 'error', title: 'DSMR configuration failed', error: 'Serial DSMR setup requires a serial port path.' }; + } + + return { + kind: 'done', + title: 'DSMR meter configured', + config: { + id: candidateArg.id || this.slug(host || serialPort || 'dsmr_meter'), + name: candidateArg.name || 'DSMR Smart Meter', + connectionType, + host: connectionType === 'network' ? host : undefined, + port: connectionType === 'network' ? port : serialPort, + serialPort: connectionType === 'serial' ? serialPort : undefined, + dsmrVersion, + dsmr_version: dsmrVersion, + protocol, + connected: false, + liveRead: connectionType === 'network' ? this.booleanValue(valuesArg.liveRead) ?? false : false, + }, + }; + }, + }; + } + + private connectionType(valueArg: unknown): TDsmrConnectionType | undefined { + return valueArg === 'serial' || valueArg === 'network' ? valueArg : undefined; + } + + private dsmrVersion(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && (dsmrVersions as string[]).includes(valueArg) ? valueArg : undefined; + } + + private protocol(valueArg: unknown): 'dsmr_protocol' | 'rfxtrx_dsmr_protocol' | undefined { + return valueArg === 'dsmr_protocol' || valueArg === 'rfxtrx_dsmr_protocol' ? valueArg : undefined; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'dsmr_meter'; + } +} diff --git a/ts/integrations/dsmr/dsmr.classes.integration.ts b/ts/integrations/dsmr/dsmr.classes.integration.ts index 7afa10d..b60f761 100644 --- a/ts/integrations/dsmr/dsmr.classes.integration.ts +++ b/ts/integrations/dsmr/dsmr.classes.integration.ts @@ -1,28 +1,94 @@ -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 { DsmrClient } from './dsmr.classes.client.js'; +import { DsmrConfigFlow } from './dsmr.classes.configflow.js'; +import { createDsmrDiscoveryDescriptor } from './dsmr.discovery.js'; +import { DsmrMapper } from './dsmr.mapper.js'; +import type { IDsmrConfig } from './dsmr.types.js'; +import { dsmrDomain } from './dsmr.types.js'; -export class HomeAssistantDsmrIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "dsmr", - displayName: "DSMR Smart Meter", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/dsmr", - "upstreamDomain": "dsmr", - "integrationType": "hub", - "iotClass": "local_push", - "requirements": [ - "dsmr-parser==1.5.0" - ], - "dependencies": [ - "usb" - ], - "afterDependencies": [], - "codeowners": [ - "@Robbie1221" - ] -}, - }); +export class DsmrIntegration extends BaseIntegration { + public readonly domain = dsmrDomain; + public readonly displayName = 'DSMR Smart Meter'; + public readonly status = 'read-only-runtime' as const; + public readonly discoveryDescriptor = createDsmrDiscoveryDescriptor(); + public readonly configFlow = new DsmrConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/dsmr', + upstreamDomain: dsmrDomain, + integrationType: 'hub', + iotClass: 'local_push', + requirements: ['dsmr-parser==1.5.0'], + dependencies: ['usb'], + afterDependencies: [], + codeowners: ['@Robbie1221'], + documentation: 'https://www.home-assistant.io/integrations/dsmr', + configFlow: true, + discovery: { + manual: ['serial', 'network'], + usb: 'Configuration supports explicit serial paths; no USB/serial probing is performed here.', + note: 'Manual serial/network setup is supported. Live serial transport is not faked; use snapshot, telegram, telegramProvider, or liveRead network refresh.', + }, + runtime: { + type: 'read-only-runtime', + mode: 'DSMR telegram/status snapshot mapping', + entities: ['sensor'], + services: ['refresh'], + }, + localApi: { + implemented: [ + 'manual DSMR serial and network meter configuration', + 'native DSMR P1 telegram parsing for common energy, gas, power, voltage, current, and diagnostic sensors', + 'status/snapshot mapping to Home Assistant-style sensor entities', + 'network telegram refresh when explicitly enabled with liveRead', + ], + explicitUnsupported: [ + 'fake live serial hardware success without a telegram source', + 'native serial port transport without an injected telegramProvider', + 'Home Assistant entity registry migrations', + ], + }, + }; + + public async setup(configArg: IDsmrConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new DsmrRuntime(new DsmrClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantDsmrIntegration extends DsmrIntegration {} + +class DsmrRuntime implements IIntegrationRuntime { + public domain = dsmrDomain; + + constructor(private readonly client: DsmrClient) {} + + public async devices(): Promise { + return DsmrMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return DsmrMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(DsmrMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain === dsmrDomain && ['refresh', 'reload'].includes(requestArg.service)) { + const result = await this.client.refresh(); + return { success: result.success, error: result.error, data: result.snapshot || result.data }; + } + return { success: false, error: `Unsupported DSMR service: ${requestArg.domain}.${requestArg.service}` }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/dsmr/dsmr.constants.ts b/ts/integrations/dsmr/dsmr.constants.ts new file mode 100644 index 0000000..f884953 --- /dev/null +++ b/ts/integrations/dsmr/dsmr.constants.ts @@ -0,0 +1,79 @@ +import type { IDsmrSensorDescription, TDsmrProtocol, TDsmrVersion } from './dsmr.types.js'; + +export const dsmrProtocol: TDsmrProtocol = 'dsmr_protocol'; +export const dsmrRfxtrxProtocol: TDsmrProtocol = 'rfxtrx_dsmr_protocol'; +export const dsmrVersions: TDsmrVersion[] = ['2.2', '4', '5', '5B', '5L', '5S', 'Q3D', '5EONHU']; + +export const dsmrObisReferenceByCode: Record = { + '0-0:1.0.0': 'P1_MESSAGE_TIMESTAMP', + '0-0:96.1.0': 'EQUIPMENT_IDENTIFIER', + '0-0:96.1.1': 'EQUIPMENT_IDENTIFIER', + '0-0:96.1.4': 'BELGIUM_EQUIPMENT_IDENTIFIER', + '1-3:0.2.8': 'DSMR_VERSION', + '1-0:1.7.0': 'CURRENT_ELECTRICITY_USAGE', + '1-0:2.7.0': 'CURRENT_ELECTRICITY_DELIVERY', + '0-0:96.14.0': 'ELECTRICITY_ACTIVE_TARIFF', + '1-0:1.8.1': 'ELECTRICITY_USED_TARIFF_1', + '1-0:1.8.2': 'ELECTRICITY_USED_TARIFF_2', + '1-0:1.8.3': 'ELECTRICITY_USED_TARIFF_3', + '1-0:1.8.4': 'ELECTRICITY_USED_TARIFF_4', + '1-0:2.8.1': 'ELECTRICITY_DELIVERED_TARIFF_1', + '1-0:2.8.2': 'ELECTRICITY_DELIVERED_TARIFF_2', + '1-0:2.8.3': 'ELECTRICITY_DELIVERED_TARIFF_3', + '1-0:2.8.4': 'ELECTRICITY_DELIVERED_TARIFF_4', + '1-0:15.8.0': 'ELECTRICITY_IMPORTED_TOTAL', + '1-0:16.8.0': 'ELECTRICITY_EXPORTED_TOTAL', + '1-0:21.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE', + '1-0:41.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE', + '1-0:61.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE', + '1-0:22.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE', + '1-0:42.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE', + '1-0:62.7.0': 'INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE', + '1-0:32.7.0': 'INSTANTANEOUS_VOLTAGE_L1', + '1-0:52.7.0': 'INSTANTANEOUS_VOLTAGE_L2', + '1-0:72.7.0': 'INSTANTANEOUS_VOLTAGE_L3', + '1-0:31.7.0': 'INSTANTANEOUS_CURRENT_L1', + '1-0:51.7.0': 'INSTANTANEOUS_CURRENT_L2', + '1-0:71.7.0': 'INSTANTANEOUS_CURRENT_L3', + '0-0:96.7.21': 'SHORT_POWER_FAILURE_COUNT', + '0-0:96.7.9': 'LONG_POWER_FAILURE_COUNT', + '0-0:96.13.0': 'TEXT_MESSAGE', + '0-1:96.1.0': 'EQUIPMENT_IDENTIFIER_GAS', + '0-1:96.1.1': 'EQUIPMENT_IDENTIFIER_GAS', + '0-1:24.1.0': 'MBUS_DEVICE_TYPE', + '0-1:24.2.1': 'HOURLY_GAS_METER_READING', + '0-1:24.3.0': 'GAS_METER_READING', +}; + +export const dsmrSensorDescriptions: IDsmrSensorDescription[] = [ + { key: 'timestamp', name: 'Timestamp', obisReference: 'P1_MESSAGE_TIMESTAMP', obisCodes: ['0-0:1.0.0'], deviceType: 'electricity', deviceClass: 'timestamp', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'current_electricity_usage', name: 'Power consumption', obisReference: 'CURRENT_ELECTRICITY_USAGE', obisCodes: ['1-0:1.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement' }, + { key: 'current_electricity_delivery', name: 'Power production', obisReference: 'CURRENT_ELECTRICITY_DELIVERY', obisCodes: ['1-0:2.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement' }, + { key: 'electricity_active_tariff', name: 'Active tariff', obisReference: 'ELECTRICITY_ACTIVE_TARIFF', obisCodes: ['0-0:96.14.0'], dsmrVersions: ['2.2', '4', '5', '5B', '5L', '5EONHU'], deviceType: 'electricity', deviceClass: 'enum', translateTariff: true }, + { key: 'electricity_used_tariff_1', name: 'Energy consumption (tarif 1)', obisReference: 'ELECTRICITY_USED_TARIFF_1', obisCodes: ['1-0:1.8.1'], dsmrVersions: ['2.2', '4', '5', '5B', '5L', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_used_tariff_2', name: 'Energy consumption (tarif 2)', obisReference: 'ELECTRICITY_USED_TARIFF_2', obisCodes: ['1-0:1.8.2'], dsmrVersions: ['2.2', '4', '5', '5B', '5L', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_used_tariff_3', name: 'Energy consumption (tarif 3)', obisReference: 'ELECTRICITY_USED_TARIFF_3', obisCodes: ['1-0:1.8.3'], dsmrVersions: ['5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_used_tariff_4', name: 'Energy consumption (tarif 4)', obisReference: 'ELECTRICITY_USED_TARIFF_4', obisCodes: ['1-0:1.8.4'], dsmrVersions: ['5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_delivered_tariff_1', name: 'Energy production (tarif 1)', obisReference: 'ELECTRICITY_DELIVERED_TARIFF_1', obisCodes: ['1-0:2.8.1'], dsmrVersions: ['2.2', '4', '5', '5B', '5L', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_delivered_tariff_2', name: 'Energy production (tarif 2)', obisReference: 'ELECTRICITY_DELIVERED_TARIFF_2', obisCodes: ['1-0:2.8.2'], dsmrVersions: ['2.2', '4', '5', '5B', '5L', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_delivered_tariff_3', name: 'Energy production (tarif 3)', obisReference: 'ELECTRICITY_DELIVERED_TARIFF_3', obisCodes: ['1-0:2.8.3'], dsmrVersions: ['5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_delivered_tariff_4', name: 'Energy production (tarif 4)', obisReference: 'ELECTRICITY_DELIVERED_TARIFF_4', obisCodes: ['1-0:2.8.4'], dsmrVersions: ['5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_imported_total', name: 'Energy consumption (total)', obisReference: 'ELECTRICITY_IMPORTED_TOTAL', obisCodes: ['1-0:15.8.0'], dsmrVersions: ['5L', '5S', 'Q3D', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'electricity_exported_total', name: 'Energy production (total)', obisReference: 'ELECTRICITY_EXPORTED_TOTAL', obisCodes: ['1-0:16.8.0'], dsmrVersions: ['5L', '5S', 'Q3D', '5EONHU'], deviceType: 'electricity', deviceClass: 'energy', stateClass: 'total_increasing' }, + { key: 'instantaneous_active_power_l1_positive', name: 'Power consumption phase L1', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE', obisCodes: ['1-0:21.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_active_power_l2_positive', name: 'Power consumption phase L2', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE', obisCodes: ['1-0:41.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_active_power_l3_positive', name: 'Power consumption phase L3', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE', obisCodes: ['1-0:61.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_active_power_l1_negative', name: 'Power production phase L1', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE', obisCodes: ['1-0:22.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_active_power_l2_negative', name: 'Power production phase L2', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE', obisCodes: ['1-0:42.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_active_power_l3_negative', name: 'Power production phase L3', obisReference: 'INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE', obisCodes: ['1-0:62.7.0'], deviceType: 'electricity', deviceClass: 'power', stateClass: 'measurement', enabledByDefault: false }, + { key: 'instantaneous_voltage_l1', name: 'Voltage phase L1', obisReference: 'INSTANTANEOUS_VOLTAGE_L1', obisCodes: ['1-0:32.7.0'], deviceType: 'electricity', deviceClass: 'voltage', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'instantaneous_voltage_l2', name: 'Voltage phase L2', obisReference: 'INSTANTANEOUS_VOLTAGE_L2', obisCodes: ['1-0:52.7.0'], deviceType: 'electricity', deviceClass: 'voltage', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'instantaneous_voltage_l3', name: 'Voltage phase L3', obisReference: 'INSTANTANEOUS_VOLTAGE_L3', obisCodes: ['1-0:72.7.0'], deviceType: 'electricity', deviceClass: 'voltage', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'instantaneous_current_l1', name: 'Current phase L1', obisReference: 'INSTANTANEOUS_CURRENT_L1', obisCodes: ['1-0:31.7.0'], deviceType: 'electricity', deviceClass: 'current', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'instantaneous_current_l2', name: 'Current phase L2', obisReference: 'INSTANTANEOUS_CURRENT_L2', obisCodes: ['1-0:51.7.0'], deviceType: 'electricity', deviceClass: 'current', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'instantaneous_current_l3', name: 'Current phase L3', obisReference: 'INSTANTANEOUS_CURRENT_L3', obisCodes: ['1-0:71.7.0'], deviceType: 'electricity', deviceClass: 'current', stateClass: 'measurement', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'short_power_failure_count', name: 'Short power failure count', obisReference: 'SHORT_POWER_FAILURE_COUNT', obisCodes: ['0-0:96.7.21'], dsmrVersions: ['2.2', '4', '5', '5L'], deviceType: 'electricity', stateClass: 'total_increasing', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'long_power_failure_count', name: 'Long power failure count', obisReference: 'LONG_POWER_FAILURE_COUNT', obisCodes: ['0-0:96.7.9'], dsmrVersions: ['2.2', '4', '5', '5L'], deviceType: 'electricity', stateClass: 'total_increasing', entityCategory: 'diagnostic', enabledByDefault: false }, + { key: 'hourly_gas_meter_reading', name: 'Gas consumption', obisReference: 'HOURLY_GAS_METER_READING', obisCodes: ['0-1:24.2.1'], obisCodePatterns: [/^0-\d+:24\.2\.1$/], dsmrVersions: ['4', '5', '5B', '5L'], deviceType: 'gas', deviceClass: 'gas', stateClass: 'total_increasing' }, + { key: 'gas_meter_reading', name: 'Gas consumption', obisReference: 'GAS_METER_READING', obisCodes: ['0-1:24.3.0'], obisCodePatterns: [/^0-\d+:24\.3\.0$/], dsmrVersions: ['2.2'], deviceType: 'gas', deviceClass: 'gas', stateClass: 'total_increasing' }, +]; diff --git a/ts/integrations/dsmr/dsmr.discovery.ts b/ts/integrations/dsmr/dsmr.discovery.ts new file mode 100644 index 0000000..e91fc55 --- /dev/null +++ b/ts/integrations/dsmr/dsmr.discovery.ts @@ -0,0 +1,141 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { dsmrVersions } from './dsmr.constants.js'; +import type { IDsmrManualEntry, TDsmrConnectionType } from './dsmr.types.js'; +import { dsmrDefaultNetworkPort, dsmrDomain } from './dsmr.types.js'; + +export class DsmrManualMatcher implements IDiscoveryMatcher { + public id = 'dsmr-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual DSMR P1 serial and network setup entries without probing live hardware.'; + + public async matches(inputArg: IDsmrManualEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const connectionType = this.connectionType(inputArg); + const serialPort = this.serialPort(inputArg); + const host = this.stringValue(inputArg.host); + const hasEndpoint = connectionType === 'network' ? Boolean(host) : Boolean(serialPort); + const hasHint = this.hasDsmrHint(inputArg); + if (!hasEndpoint || !hasHint) { + return { + matched: false, + confidence: 'low', + reason: hasEndpoint ? 'Manual entry does not contain a DSMR/P1 hint.' : 'Manual entry does not contain a DSMR serial path or network host.', + }; + } + const id = this.normalizedId(inputArg, connectionType, serialPort); + return { + matched: true, + confidence: inputArg.metadata?.dsmr || inputArg.metadata?.p1 || inputArg.protocol === dsmrDomain ? 'high' : 'medium', + reason: `Manual entry can be configured as a DSMR ${connectionType} meter.`, + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: dsmrDomain, + id, + host: connectionType === 'network' ? host : undefined, + port: connectionType === 'network' ? this.numberValue(inputArg.port) || dsmrDefaultNetworkPort : undefined, + name: inputArg.name || 'DSMR Smart Meter', + manufacturer: inputArg.manufacturer || 'DSMR', + model: inputArg.model || 'P1 smart meter', + serialNumber: inputArg.serialNumber, + metadata: { + ...inputArg.metadata, + connectionType, + serialPort: connectionType === 'serial' ? serialPort : undefined, + protocol: this.protocol(inputArg.protocol || inputArg.metadata?.protocol), + dsmrVersion: this.dsmrVersion(inputArg.dsmrVersion || inputArg.metadata?.dsmrVersion || inputArg.metadata?.dsmr_version), + liveValidation: false, + }, + }, + }; + } + + private connectionType(inputArg: IDsmrManualEntry): TDsmrConnectionType { + const value = String(inputArg.type || inputArg.metadata?.connectionType || '').toLowerCase(); + if (value === 'network' || inputArg.host) { + return 'network'; + } + return 'serial'; + } + + private hasDsmrHint(inputArg: IDsmrManualEntry): boolean { + const haystack = `${inputArg.name || ''} ${inputArg.protocol || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + return inputArg.metadata?.dsmr === true + || inputArg.metadata?.p1 === true + || inputArg.metadata?.smartMeter === true + || inputArg.protocol === dsmrDomain + || haystack.includes('dsmr') + || haystack.includes('p1') + || haystack.includes('smart meter') + || haystack.includes('slimme meter'); + } + + private normalizedId(inputArg: IDsmrManualEntry, connectionTypeArg: TDsmrConnectionType, serialPortArg: string | undefined): string { + const endpoint = connectionTypeArg === 'network' + ? `${inputArg.host || 'dsmr'}_${this.numberValue(inputArg.port) || dsmrDefaultNetworkPort}` + : serialPortArg || inputArg.serialNumber || 'dsmr_serial'; + return slug(String(inputArg.id || inputArg.serialNumber || endpoint)); + } + + private serialPort(inputArg: IDsmrManualEntry): string | undefined { + const value = inputArg.serialPort || inputArg.device || inputArg.path || (typeof inputArg.port === 'string' && !Number.isFinite(Number(inputArg.port)) ? inputArg.port : undefined); + return this.stringValue(value); + } + + private protocol(valueArg: unknown): string { + return valueArg === 'rfxtrx_dsmr_protocol' ? 'rfxtrx_dsmr_protocol' : 'dsmr_protocol'; + } + + private dsmrVersion(valueArg: unknown): string { + return typeof valueArg === 'string' && dsmrVersions.includes(valueArg as never) ? valueArg : '2.2'; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } +} + +export class DsmrCandidateValidator implements IDiscoveryValidator { + public id = 'dsmr-candidate-validator'; + public description = 'Validate DSMR candidates have explicit serial or network setup data without claiming live connectivity.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + const connectionType = String(candidateArg.metadata?.connectionType || (candidateArg.host ? 'network' : 'serial')).toLowerCase() as TDsmrConnectionType; + const serialPort = typeof candidateArg.metadata?.serialPort === 'string' ? candidateArg.metadata.serialPort : undefined; + const matched = candidateArg.integrationDomain === dsmrDomain && (connectionType === 'network' ? Boolean(candidateArg.host) : Boolean(serialPort)); + const id = slug(candidateArg.id || candidateArg.serialNumber || (connectionType === 'network' ? `${candidateArg.host || 'dsmr'}_${candidateArg.port || dsmrDefaultNetworkPort}` : serialPort || 'dsmr_serial')); + return { + matched, + confidence: matched ? 'high' : 'low', + reason: matched ? 'Candidate has explicit DSMR setup data; live communication is not assumed.' : 'Candidate is missing DSMR serial or network setup data.', + normalizedDeviceId: matched ? id : undefined, + candidate: matched ? { + ...candidateArg, + id, + integrationDomain: dsmrDomain, + port: connectionType === 'network' ? candidateArg.port || dsmrDefaultNetworkPort : candidateArg.port, + manufacturer: candidateArg.manufacturer || 'DSMR', + model: candidateArg.model || 'P1 smart meter', + metadata: { + ...candidateArg.metadata, + connectionType, + liveValidation: false, + }, + } : undefined, + }; + } +} + +export const createDsmrDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: dsmrDomain, displayName: 'DSMR Smart Meter' }) + .addMatcher(new DsmrManualMatcher()) + .addValidator(new DsmrCandidateValidator()); +}; + +const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || dsmrDomain; diff --git a/ts/integrations/dsmr/dsmr.mapper.ts b/ts/integrations/dsmr/dsmr.mapper.ts new file mode 100644 index 0000000..700272b --- /dev/null +++ b/ts/integrations/dsmr/dsmr.mapper.ts @@ -0,0 +1,143 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { IDsmrEvent, IDsmrSensorState, IDsmrSnapshot, TDsmrDeviceType } from './dsmr.types.js'; +import { dsmrDomain } from './dsmr.types.js'; + +export class DsmrMapper { + public static toDevices(snapshotArg: IDsmrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.deviceForType(snapshotArg, 'electricity', updatedAt)]; + if (snapshotArg.meter.serialIdGas || snapshotArg.sensors.some((sensorArg) => sensorArg.deviceType === 'gas')) { + devices.push(this.deviceForType(snapshotArg, 'gas', updatedAt)); + } + if (snapshotArg.sensors.some((sensorArg) => sensorArg.deviceType === 'water')) { + devices.push(this.deviceForType(snapshotArg, 'water', updatedAt)); + } + if (snapshotArg.sensors.some((sensorArg) => sensorArg.deviceType === 'heat')) { + devices.push(this.deviceForType(snapshotArg, 'heat', updatedAt)); + } + return devices; + } + + public static toEntities(snapshotArg: IDsmrSnapshot): IIntegrationEntity[] { + const usedIds = new Map(); + return snapshotArg.sensors.map((sensorArg) => this.toEntity(snapshotArg, sensorArg, usedIds)); + } + + public static toIntegrationEvent(eventArg: IDsmrEvent): IIntegrationEvent { + return { + type: eventArg.type === 'refresh_failed' ? 'error' : 'state_changed', + integrationDomain: dsmrDomain, + deviceId: eventArg.snapshot && eventArg.sensor ? this.deviceId(eventArg.snapshot, eventArg.sensor.deviceType) : undefined, + entityId: eventArg.snapshot && eventArg.sensor ? this.entityId(eventArg.sensor, new Map()) : undefined, + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static deviceId(snapshotArg: IDsmrSnapshot, deviceTypeArg: TDsmrDeviceType): string { + const serial = deviceTypeArg === 'gas' + ? snapshotArg.meter.serialIdGas || `${snapshotArg.meter.id}_gas` + : snapshotArg.meter.serialId || `${snapshotArg.meter.id}_${deviceTypeArg}`; + return `${dsmrDomain}.${deviceTypeArg}.${this.slug(String(serial || snapshotArg.meter.id))}`; + } + + public static entityId(sensorArg: IDsmrSensorState, usedIdsArg: Map): string { + const baseId = `sensor.${this.slug(`${this.deviceName(sensorArg.deviceType)} ${sensorArg.name}`)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return seen ? `${baseId}_${seen + 1}` : baseId; + } + + private static deviceForType(snapshotArg: IDsmrSnapshot, deviceTypeArg: TDsmrDeviceType, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const sensors = snapshotArg.sensors.filter((sensorArg) => sensorArg.deviceType === deviceTypeArg); + return { + id: this.deviceId(snapshotArg, deviceTypeArg), + integrationDomain: dsmrDomain, + name: this.deviceName(deviceTypeArg), + protocol: 'unknown', + manufacturer: snapshotArg.meter.manufacturer || 'DSMR', + model: deviceTypeArg === 'electricity' ? snapshotArg.meter.model || `DSMR ${snapshotArg.meter.dsmrVersion}` : `${this.deviceName(deviceTypeArg)} DSMR meter`, + online: snapshotArg.connected, + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + ...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.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + ...sensors.map((sensorArg) => ({ featureId: sensorArg.key, value: sensorArg.value, updatedAt: sensorArg.updatedAt || updatedAtArg })), + ], + metadata: this.cleanAttributes({ + source: snapshotArg.source, + dsmrVersion: snapshotArg.meter.dsmrVersion, + connectionType: snapshotArg.meter.connectionType, + host: snapshotArg.meter.host, + port: snapshotArg.meter.port, + serialPort: snapshotArg.meter.serialPort, + protocol: snapshotArg.meter.protocol, + serialId: deviceTypeArg === 'gas' ? snapshotArg.meter.serialIdGas : snapshotArg.meter.serialId, + }), + }; + } + + private static toEntity(snapshotArg: IDsmrSnapshot, sensorArg: IDsmrSensorState, usedIdsArg: Map): IIntegrationEntity { + return { + id: this.entityId(sensorArg, usedIdsArg), + uniqueId: this.uniqueId(snapshotArg, sensorArg), + integrationDomain: dsmrDomain, + deviceId: this.deviceId(snapshotArg, sensorArg.deviceType), + platform: 'sensor', + name: sensorArg.name, + state: sensorArg.value, + attributes: this.cleanAttributes({ + key: sensorArg.key, + obisReference: sensorArg.obisReference, + obisCode: sensorArg.obisCode, + unitOfMeasurement: sensorArg.unit, + deviceClass: sensorArg.deviceClass, + stateClass: sensorArg.stateClass, + entityCategory: sensorArg.entityCategory, + enabledByDefault: sensorArg.enabledByDefault, + channel: sensorArg.channel, + rawValue: sensorArg.rawValue, + dsmrVersion: snapshotArg.meter.dsmrVersion, + source: snapshotArg.source, + ...sensorArg.attributes, + }), + available: snapshotArg.connected && sensorArg.available, + }; + } + + private static uniqueId(snapshotArg: IDsmrSnapshot, sensorArg: IDsmrSensorState): string { + const serial = sensorArg.serialId || (sensorArg.deviceType === 'gas' ? snapshotArg.meter.serialIdGas : snapshotArg.meter.serialId) || snapshotArg.meter.id; + return `${this.slug(String(serial))}_${sensorArg.key}`; + } + + private static deviceName(deviceTypeArg: TDsmrDeviceType): string { + if (deviceTypeArg === 'gas') { + return 'Gas Meter'; + } + if (deviceTypeArg === 'water') { + return 'Water Meter'; + } + if (deviceTypeArg === 'heat') { + return 'Heat Meter'; + } + return 'Electricity Meter'; + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || dsmrDomain; + } +} diff --git a/ts/integrations/dsmr/dsmr.parser.ts b/ts/integrations/dsmr/dsmr.parser.ts new file mode 100644 index 0000000..8da206e --- /dev/null +++ b/ts/integrations/dsmr/dsmr.parser.ts @@ -0,0 +1,533 @@ +import { dsmrObisReferenceByCode, dsmrProtocol, dsmrSensorDescriptions, dsmrVersions } from './dsmr.constants.js'; +import type { + IDsmrConfig, + IDsmrConnectionInfo, + IDsmrMbusDeviceSnapshot, + IDsmrMeterInfo, + IDsmrSensorDescription, + IDsmrSensorState, + IDsmrSnapshot, + IDsmrStatusSnapshot, + IDsmrStatusValueObject, + IDsmrTelegramObject, + IDsmrTelegramObjectGroup, + TDsmrConnectionType, + TDsmrProtocol, + TDsmrSensorValue, + TDsmrSnapshotSource, + TDsmrVersion, +} from './dsmr.types.js'; +import { dsmrDefaultDsmrVersion, dsmrDefaultNetworkPort, dsmrDefaultPrecision, dsmrDomain } from './dsmr.types.js'; + +type TDsmrConnectionConfig = Partial> & { + id?: string; + name?: string; + connectionType?: TDsmrConnectionType; + type?: string; + host?: string; + port?: number | string; + serialPort?: string; + device?: string; + path?: string; + protocol?: string; + dsmrVersion?: string; + dsmr_version?: string; + serialId?: string | null; + serialIdGas?: string | null; + manufacturer?: string; + model?: string; +}; + +export interface IDsmrParserOptions { + config?: IDsmrConfig; + source?: TDsmrSnapshotSource; + connected?: boolean; + updatedAt?: string; + dsmrVersion?: string; + serialId?: string | null; + serialIdGas?: string | null; +} + +export class DsmrTelegramParser { + public static parseTelegram(telegramArg: string, optionsArg: IDsmrParserOptions = {}): IDsmrSnapshot { + const updatedAt = optionsArg.updatedAt || new Date().toISOString(); + const lines = telegramArg.split(/\r?\n/).map((lineArg) => lineArg.trim()).filter(Boolean); + const header = lines.find((lineArg) => lineArg.startsWith('/')); + const checksumLine = lines.find((lineArg) => lineArg.startsWith('!')); + const checksum = checksumLine?.slice(1).trim() || undefined; + const objects = lines.map((lineArg) => this.parseLine(lineArg)).filter((objectArg): objectArg is IDsmrTelegramObject => Boolean(objectArg)); + const dsmrVersion = this.dsmrVersion(optionsArg.config, optionsArg.dsmrVersion, objects); + const meter = this.meterInfo(optionsArg.config, dsmrVersion, objects, optionsArg.serialId, optionsArg.serialIdGas); + const sensors = this.sensorsFromObjects(objects, meter, dsmrVersion, updatedAt); + const mbusDevices = this.mbusDevicesFromObjects(objects, sensors); + + return { + meter, + sensors, + mbusDevices, + telegram: { + header, + checksum, + raw: telegramArg, + objects, + }, + connected: optionsArg.connected ?? objects.length > 0, + source: optionsArg.source || 'telegram', + updatedAt, + raw: { + objectCount: objects.length, + }, + }; + } + + public static parseLine(lineArg: string): IDsmrTelegramObject | undefined { + const line = lineArg.trim(); + const start = line.indexOf('('); + if (start <= 0 || line.startsWith('/') || line.startsWith('!')) { + return undefined; + } + const obisCode = line.slice(0, start).trim(); + if (!/^\d-\d:/.test(obisCode)) { + return undefined; + } + const groups = [...line.matchAll(/\(([^()]*)\)/g)].map((matchArg) => this.parseGroup(matchArg[1])); + if (!groups.length) { + return undefined; + } + const valueGroup = this.valueGroup(groups); + return { + obisCode, + obisReference: this.obisReference(obisCode), + channel: this.channelForCode(obisCode), + raw: line, + groups, + value: valueGroup?.value, + unit: this.normalizeUnit(valueGroup?.unit), + }; + } + + public static snapshotFromStatus(statusArg: IDsmrStatusSnapshot, configArg: IDsmrConfig = {}): IDsmrSnapshot { + const updatedAt = statusArg.updatedAt || new Date().toISOString(); + const connection = this.connectionInfo({ ...configArg, ...statusArg.meter }); + const dsmrVersion = String(statusArg.meter?.dsmrVersion || connection.dsmrVersion || dsmrDefaultDsmrVersion); + const meter: IDsmrMeterInfo = { + ...connection, + ...statusArg.meter, + id: String(statusArg.meter?.id || connection.id), + name: String(statusArg.meter?.name || connection.name), + connectionType: this.connectionType(statusArg.meter || configArg), + protocol: this.protocol(statusArg.meter?.protocol || configArg.protocol), + dsmrVersion, + serialId: statusArg.meter?.serialId ?? configArg.serialId ?? configArg.serial_id ?? null, + serialIdGas: statusArg.meter?.serialIdGas ?? configArg.serialIdGas ?? configArg.serial_id_gas ?? null, + }; + const configuredSensors = Array.isArray(statusArg.sensors) ? statusArg.sensors.map((sensorArg) => this.normalizeSensor(sensorArg, dsmrVersion, updatedAt)) : []; + const sensors = configuredSensors.length ? configuredSensors : dsmrSensorDescriptions.flatMap((descriptionArg) => { + if (!this.supportedDescription(descriptionArg, dsmrVersion)) { + return []; + } + const statusValue = this.statusValue(statusArg, descriptionArg); + if (!statusValue.exists) { + return []; + } + const value = this.sensorValue(statusValue.value, descriptionArg, dsmrVersion); + return [{ + key: descriptionArg.key, + name: descriptionArg.name, + obisReference: descriptionArg.obisReference, + obisCode: descriptionArg.obisCodes?.[0], + deviceType: descriptionArg.deviceType, + value, + rawValue: statusValue.rawValue ?? (statusValue.value === undefined || statusValue.value === null ? undefined : String(statusValue.value)), + unit: this.normalizeUnit(statusValue.unit || descriptionArg.defaultUnit), + deviceClass: descriptionArg.deviceClass, + stateClass: descriptionArg.stateClass, + entityCategory: descriptionArg.entityCategory, + enabledByDefault: descriptionArg.enabledByDefault ?? true, + available: statusValue.available ?? true, + serialId: descriptionArg.deviceType === 'gas' ? meter.serialIdGas : meter.serialId, + updatedAt: statusValue.updatedAt || updatedAt, + attributes: statusValue.attributes, + } satisfies IDsmrSensorState]; + }); + + return { + meter, + sensors, + connected: statusArg.connected ?? configArg.connected ?? true, + source: 'status', + updatedAt, + raw: statusArg.raw || { status: true }, + }; + } + + public static emptySnapshot(configArg: IDsmrConfig = {}, connectedArg = configArg.connected ?? false): IDsmrSnapshot { + const connection = this.connectionInfo(configArg); + return { + meter: { + ...connection, + serialId: configArg.serialId ?? configArg.serial_id ?? null, + serialIdGas: configArg.serialIdGas ?? configArg.serial_id_gas ?? null, + }, + sensors: [], + connected: connectedArg, + source: 'manual', + updatedAt: new Date().toISOString(), + raw: { configuredOnly: true }, + }; + } + + public static normalizeSnapshot(snapshotArg: IDsmrSnapshot, configArg: IDsmrConfig = {}): IDsmrSnapshot { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const dsmrVersion = String(snapshotArg.meter?.dsmrVersion || configArg.dsmrVersion || configArg.dsmr_version || dsmrDefaultDsmrVersion); + const connection = this.connectionInfo({ ...configArg, ...snapshotArg.meter, dsmrVersion }); + const meter: IDsmrMeterInfo = { + ...connection, + ...snapshotArg.meter, + dsmrVersion, + protocol: this.protocol(snapshotArg.meter?.protocol || configArg.protocol), + serialId: snapshotArg.meter?.serialId ?? configArg.serialId ?? configArg.serial_id ?? null, + serialIdGas: snapshotArg.meter?.serialIdGas ?? configArg.serialIdGas ?? configArg.serial_id_gas ?? null, + }; + return { + ...snapshotArg, + meter, + sensors: snapshotArg.sensors.map((sensorArg) => this.normalizeSensor(sensorArg, dsmrVersion, updatedAt)), + connected: snapshotArg.connected, + updatedAt, + }; + } + + public static connectionInfo(configArg: TDsmrConnectionConfig = {}): IDsmrConnectionInfo { + const connectionType = this.connectionType(configArg); + const serialPort = this.serialPort(configArg); + const host = typeof configArg.host === 'string' && configArg.host.trim() ? configArg.host.trim() : undefined; + const port = connectionType === 'network' ? this.networkPort(configArg.port) : serialPort; + const id = this.slug(String(configArg.id || (host ? `${host}_${port || dsmrDefaultNetworkPort}` : serialPort || 'dsmr_meter'))); + return { + id, + name: typeof configArg.name === 'string' && configArg.name.trim() ? configArg.name.trim() : 'DSMR Smart Meter', + connectionType, + host: connectionType === 'network' ? host : undefined, + port, + serialPort: connectionType === 'serial' ? serialPort : undefined, + protocol: this.protocol(configArg.protocol), + dsmrVersion: this.dsmrVersion(configArg as IDsmrConfig), + }; + } + + public static translateTariff(valueArg: string | number | boolean | null | undefined, dsmrVersionArg: string): string | null { + let value = String(valueArg ?? '').trim(); + if (/^\d$/.test(value)) { + value = `000${value}`; + } + if (dsmrVersionArg === '5B' || dsmrVersionArg === '5EONHU') { + if (value === '0001') { + value = '0002'; + } else if (value === '0002') { + value = '0001'; + } + } + if (value === '0002') { + return 'normal'; + } + if (value === '0001') { + return 'low'; + } + return null; + } + + private static sensorsFromObjects(objectsArg: IDsmrTelegramObject[], meterArg: IDsmrMeterInfo, dsmrVersionArg: string, updatedAtArg: string): IDsmrSensorState[] { + const mbusDeviceTypes = new Map(); + for (const object of objectsArg) { + if (object.obisReference === 'MBUS_DEVICE_TYPE' && object.channel !== undefined) { + const deviceType = this.numberValue(object.value); + if (deviceType !== undefined) { + mbusDeviceTypes.set(object.channel, deviceType); + } + } + } + return dsmrSensorDescriptions.flatMap((descriptionArg) => { + if (!this.supportedDescription(descriptionArg, dsmrVersionArg)) { + return []; + } + const object = this.objectForDescription(objectsArg, descriptionArg); + if (!object) { + return []; + } + if (descriptionArg.deviceType === 'gas' && object.channel !== undefined) { + const mbusDeviceType = mbusDeviceTypes.get(object.channel); + if (mbusDeviceType !== undefined && mbusDeviceType !== 3) { + return []; + } + } + return [{ + key: descriptionArg.key, + name: descriptionArg.name, + obisReference: descriptionArg.obisReference, + obisCode: object.obisCode, + deviceType: descriptionArg.deviceType, + value: this.sensorValue(object.value, descriptionArg, dsmrVersionArg), + rawValue: object.value, + unit: this.normalizeUnit(object.unit || descriptionArg.defaultUnit), + deviceClass: descriptionArg.deviceClass, + stateClass: descriptionArg.stateClass, + entityCategory: descriptionArg.entityCategory, + enabledByDefault: descriptionArg.enabledByDefault ?? true, + available: true, + channel: object.channel, + serialId: descriptionArg.deviceType === 'gas' ? meterArg.serialIdGas : meterArg.serialId, + updatedAt: updatedAtArg, + } satisfies IDsmrSensorState]; + }); + } + + private static mbusDevicesFromObjects(objectsArg: IDsmrTelegramObject[], sensorsArg: IDsmrSensorState[]): IDsmrMbusDeviceSnapshot[] { + const devices = new Map(); + for (const object of objectsArg) { + if (object.channel === undefined) { + continue; + } + const device = devices.get(object.channel) || { channel: object.channel }; + if (object.obisReference === 'MBUS_DEVICE_TYPE') { + device.deviceType = this.numberValue(object.value); + } + if (object.obisReference === 'MBUS_EQUIPMENT_IDENTIFIER' || object.obisReference === 'EQUIPMENT_IDENTIFIER_GAS') { + device.serialId = object.value; + } + devices.set(object.channel, device); + } + for (const sensor of sensorsArg) { + if (sensor.channel === undefined || sensor.deviceType !== 'gas') { + continue; + } + const device = devices.get(sensor.channel) || { channel: sensor.channel }; + device.reading = sensor; + devices.set(sensor.channel, device); + } + return [...devices.values()]; + } + + private static meterInfo(configArg: IDsmrConfig | undefined, dsmrVersionArg: string, objectsArg: IDsmrTelegramObject[], serialIdArg?: string | null, serialIdGasArg?: string | null): IDsmrMeterInfo { + const connection = this.connectionInfo({ ...configArg, dsmrVersion: dsmrVersionArg }); + const serialId = serialIdArg ?? configArg?.serialId ?? configArg?.serial_id ?? this.objectByReference(objectsArg, 'EQUIPMENT_IDENTIFIER')?.value ?? this.objectByReference(objectsArg, 'BELGIUM_EQUIPMENT_IDENTIFIER')?.value ?? null; + const gasObject = this.objectByReference(objectsArg, 'EQUIPMENT_IDENTIFIER_GAS') || this.objectByReference(objectsArg, 'MBUS_EQUIPMENT_IDENTIFIER'); + const serialIdGas = serialIdGasArg ?? configArg?.serialIdGas ?? configArg?.serial_id_gas ?? gasObject?.value ?? null; + return { + ...connection, + dsmrVersion: dsmrVersionArg, + serialId, + serialIdGas, + manufacturer: 'DSMR', + model: `DSMR ${dsmrVersionArg}`, + }; + } + + private static objectForDescription(objectsArg: IDsmrTelegramObject[], descriptionArg: IDsmrSensorDescription): IDsmrTelegramObject | undefined { + return objectsArg.find((objectArg) => { + if (descriptionArg.obisCodes?.includes(objectArg.obisCode)) { + return true; + } + if (descriptionArg.obisCodePatterns?.some((patternArg) => patternArg.test(objectArg.obisCode))) { + return true; + } + return objectArg.obisReference === descriptionArg.obisReference; + }); + } + + private static supportedDescription(descriptionArg: IDsmrSensorDescription, dsmrVersionArg: string): boolean { + return !descriptionArg.dsmrVersions || descriptionArg.dsmrVersions.includes(dsmrVersionArg); + } + + private static sensorValue(valueArg: unknown, descriptionArg: IDsmrSensorDescription, dsmrVersionArg: string): TDsmrSensorValue { + if (descriptionArg.translateTariff) { + return this.translateTariff(valueArg as string | number | boolean | null | undefined, dsmrVersionArg); + } + if (valueArg === undefined || valueArg === null) { + return null; + } + if (typeof valueArg === 'boolean') { + return valueArg; + } + const numericValue = this.numberValue(valueArg); + if (numericValue === undefined) { + return String(valueArg); + } + if (descriptionArg.stateClass === 'total_increasing' && !numericValue) { + return null; + } + return Number(numericValue.toFixed(dsmrDefaultPrecision)); + } + + private static normalizeSensor(sensorArg: IDsmrSensorState, dsmrVersionArg: string, updatedAtArg: string): IDsmrSensorState { + const description = dsmrSensorDescriptions.find((descriptionArg) => descriptionArg.key === sensorArg.key || descriptionArg.obisReference === sensorArg.obisReference); + const value = description ? this.sensorValue(sensorArg.value, description, dsmrVersionArg) : sensorArg.value; + return { + ...sensorArg, + name: sensorArg.name || description?.name || sensorArg.key, + obisReference: sensorArg.obisReference || description?.obisReference || sensorArg.key, + deviceType: sensorArg.deviceType || description?.deviceType || 'electricity', + value, + unit: this.normalizeUnit(sensorArg.unit || description?.defaultUnit), + deviceClass: sensorArg.deviceClass || description?.deviceClass, + stateClass: sensorArg.stateClass || description?.stateClass, + entityCategory: sensorArg.entityCategory || description?.entityCategory, + enabledByDefault: sensorArg.enabledByDefault ?? description?.enabledByDefault ?? true, + available: sensorArg.available, + updatedAt: sensorArg.updatedAt || updatedAtArg, + }; + } + + private static statusValue(statusArg: IDsmrStatusSnapshot, descriptionArg: IDsmrSensorDescription): { exists: boolean; value?: TDsmrSensorValue; unit?: string; rawValue?: string; available?: boolean; updatedAt?: string; attributes?: Record } { + const directValue = statusArg[descriptionArg.key] ?? statusArg[descriptionArg.obisReference]; + const values = statusArg.values || {}; + const value = values[descriptionArg.key] ?? values[descriptionArg.obisReference] ?? directValue; + if (value === undefined) { + return { exists: false }; + } + if (this.isStatusValueObject(value)) { + return { + exists: true, + value: value.value, + unit: value.unit, + rawValue: value.rawValue, + available: value.available, + updatedAt: value.updatedAt, + attributes: value.attributes, + }; + } + return { exists: true, value: value as TDsmrSensorValue }; + } + + private static isStatusValueObject(valueArg: unknown): valueArg is IDsmrStatusValueObject { + return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) && ('value' in valueArg || 'unit' in valueArg || 'rawValue' in valueArg || 'available' in valueArg)); + } + + private static parseGroup(groupArg: string): IDsmrTelegramObjectGroup { + const starIndex = groupArg.indexOf('*'); + if (starIndex >= 0) { + return { + raw: groupArg, + value: groupArg.slice(0, starIndex).trim(), + unit: groupArg.slice(starIndex + 1).trim() || undefined, + }; + } + return { + raw: groupArg, + value: groupArg.trim(), + }; + } + + private static valueGroup(groupsArg: IDsmrTelegramObjectGroup[]): IDsmrTelegramObjectGroup | undefined { + for (let index = groupsArg.length - 1; index >= 0; index--) { + if (groupsArg[index].unit) { + return groupsArg[index]; + } + } + return groupsArg[groupsArg.length - 1]; + } + + private static objectByReference(objectsArg: IDsmrTelegramObject[], referenceArg: string): IDsmrTelegramObject | undefined { + return objectsArg.find((objectArg) => objectArg.obisReference === referenceArg); + } + + private static obisReference(obisCodeArg: string): string | undefined { + if (dsmrObisReferenceByCode[obisCodeArg]) { + return dsmrObisReferenceByCode[obisCodeArg]; + } + if (/^0-\d+:24\.1\.0$/.test(obisCodeArg)) { + return 'MBUS_DEVICE_TYPE'; + } + if (/^0-\d+:24\.2\.1$/.test(obisCodeArg)) { + return 'HOURLY_GAS_METER_READING'; + } + if (/^0-\d+:24\.3\.0$/.test(obisCodeArg)) { + return 'GAS_METER_READING'; + } + if (/^0-\d+:96\.1\.[01]$/.test(obisCodeArg)) { + return this.channelForCode(obisCodeArg) ? 'MBUS_EQUIPMENT_IDENTIFIER' : 'EQUIPMENT_IDENTIFIER'; + } + return undefined; + } + + private static channelForCode(obisCodeArg: string): number | undefined { + const match = obisCodeArg.match(/^0-(\d+):/); + if (!match) { + return undefined; + } + const channel = Number(match[1]); + return channel > 0 ? channel : undefined; + } + + private static dsmrVersion(configArg?: TDsmrConnectionConfig, overrideArg?: string, objectsArg: IDsmrTelegramObject[] = []): string { + const configured = overrideArg || configArg?.dsmrVersion || configArg?.dsmr_version; + if (typeof configured === 'string' && configured.trim()) { + return configured.trim(); + } + const versionObject = this.objectByReference(objectsArg, 'DSMR_VERSION'); + const rawVersion = versionObject?.value; + if (rawVersion === '22') { + return '2.2'; + } + if (rawVersion === '40' || rawVersion === '42') { + return '4'; + } + if (rawVersion === '50') { + return '5'; + } + return dsmrDefaultDsmrVersion; + } + + private static protocol(valueArg: unknown): TDsmrProtocol { + return valueArg === 'rfxtrx_dsmr_protocol' ? 'rfxtrx_dsmr_protocol' : dsmrProtocol; + } + + private static connectionType(configArg: TDsmrConnectionConfig): TDsmrConnectionType { + const value = String(configArg.connectionType || configArg.type || '').toLowerCase(); + if (value === 'network') { + return 'network'; + } + if (value === 'serial') { + return 'serial'; + } + if (typeof configArg.host === 'string' && configArg.host.trim()) { + return 'network'; + } + return 'serial'; + } + + private static serialPort(configArg: TDsmrConnectionConfig): string | undefined { + const value = configArg.serialPort || configArg.device || configArg.path || (typeof configArg.port === 'string' && !Number.isFinite(Number(configArg.port)) ? configArg.port : undefined); + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static networkPort(valueArg: unknown): number { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return dsmrDefaultNetworkPort; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) { + return Number(valueArg); + } + return undefined; + } + + private static normalizeUnit(unitArg: string | undefined): string | undefined { + return unitArg?.trim() || undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || dsmrDomain; + } +} + +export const isDsmrVersion = (valueArg: unknown): valueArg is TDsmrVersion => typeof valueArg === 'string' && (dsmrVersions as string[]).includes(valueArg); diff --git a/ts/integrations/dsmr/dsmr.types.ts b/ts/integrations/dsmr/dsmr.types.ts index 38ada01..65ef319 100644 --- a/ts/integrations/dsmr/dsmr.types.ts +++ b/ts/integrations/dsmr/dsmr.types.ts @@ -1,4 +1,192 @@ -export interface IHomeAssistantDsmrConfig { - // TODO: replace with the TypeScript-native config for dsmr. +export const dsmrDomain = 'dsmr'; +export const dsmrDefaultDsmrVersion = '2.2'; +export const dsmrDefaultNetworkPort = 2001; +export const dsmrDefaultTimeoutMs = 30000; +export const dsmrDefaultPrecision = 3; + +export type TDsmrConnectionType = 'serial' | 'network'; +export type TDsmrProtocol = 'dsmr_protocol' | 'rfxtrx_dsmr_protocol'; +export type TDsmrVersion = '2.2' | '4' | '5' | '5B' | '5L' | '5S' | 'Q3D' | '5EONHU'; +export type TDsmrDeviceType = 'electricity' | 'gas' | 'water' | 'heat'; +export type TDsmrSnapshotSource = 'manual' | 'snapshot' | 'status' | 'telegram' | 'network' | 'serial'; +export type TDsmrStateClass = 'measurement' | 'total' | 'total_increasing'; +export type TDsmrEventType = 'snapshot_refreshed' | 'telegram_received' | 'refresh_failed' | 'connection_status'; +export type TDsmrSensorValue = string | number | boolean | null; + +export interface IDsmrConfig { + id?: string; + name?: string; + connectionType?: TDsmrConnectionType; + type?: TDsmrConnectionType | 'Serial' | 'Network'; + host?: string; + port?: number | string; + serialPort?: string; + device?: string; + path?: string; + dsmrVersion?: TDsmrVersion | string; + dsmr_version?: TDsmrVersion | string; + protocol?: TDsmrProtocol | string; + serialId?: string | null; + serial_id?: string | null; + serialIdGas?: string | null; + serial_id_gas?: string | null; + timeBetweenUpdate?: number; + time_between_update?: number; + connected?: boolean; + timeoutMs?: number; + liveRead?: boolean; + snapshot?: IDsmrSnapshot; + status?: IDsmrStatusSnapshot; + telegram?: string | IDsmrSnapshot; + telegrams?: string[]; + telegramProvider?: () => Promise; +} + +export interface IHomeAssistantDsmrConfig extends IDsmrConfig {} + +export interface IDsmrConnectionInfo { + id: string; + name: string; + connectionType: TDsmrConnectionType; + host?: string; + port?: number | string; + serialPort?: string; + protocol: TDsmrProtocol; + dsmrVersion: TDsmrVersion | string; +} + +export interface IDsmrMeterInfo extends IDsmrConnectionInfo { + serialId?: string | null; + serialIdGas?: string | null; + manufacturer?: string; + model?: string; +} + +export interface IDsmrSensorDescription { + key: string; + name: string; + obisReference: string; + obisCodes?: string[]; + obisCodePatterns?: RegExp[]; + dsmrVersions?: string[]; + deviceType: TDsmrDeviceType; + deviceClass?: string; + stateClass?: TDsmrStateClass; + entityCategory?: 'diagnostic'; + enabledByDefault?: boolean; + defaultUnit?: string; + translateTariff?: boolean; +} + +export interface IDsmrTelegramObjectGroup { + raw: string; + value: string; + unit?: string; +} + +export interface IDsmrTelegramObject { + obisCode: string; + obisReference?: string; + channel?: number; + raw: string; + groups: IDsmrTelegramObjectGroup[]; + value?: string; + unit?: string; +} + +export interface IDsmrMbusDeviceSnapshot { + channel: number; + deviceType?: number; + serialId?: string; + reading?: IDsmrSensorState; +} + +export interface IDsmrSensorState { + key: string; + name: string; + obisReference: string; + obisCode?: string; + deviceType: TDsmrDeviceType; + value: TDsmrSensorValue; + rawValue?: string; + unit?: string; + deviceClass?: string; + stateClass?: TDsmrStateClass; + entityCategory?: 'diagnostic'; + enabledByDefault?: boolean; + available: boolean; + channel?: number; + serialId?: string | null; + updatedAt?: string; + attributes?: Record; +} + +export interface IDsmrTelegramData { + header?: string; + checksum?: string; + raw: string; + objects: IDsmrTelegramObject[]; +} + +export interface IDsmrSnapshot { + meter: IDsmrMeterInfo; + sensors: IDsmrSensorState[]; + mbusDevices?: IDsmrMbusDeviceSnapshot[]; + telegram?: IDsmrTelegramData; + connected: boolean; + source: TDsmrSnapshotSource; + updatedAt: string; + raw?: Record; +} + +export interface IDsmrStatusValueObject { + value?: TDsmrSensorValue | undefined; + unit?: string; + rawValue?: string; + available?: boolean; + updatedAt?: string; + attributes?: Record; +} + +export interface IDsmrStatusSnapshot { + meter?: Partial; + sensors?: IDsmrSensorState[]; + values?: Record; + connected?: boolean; + updatedAt?: string; + raw?: Record; [key: string]: unknown; } + +export interface IDsmrRefreshResult { + success: boolean; + snapshot?: IDsmrSnapshot; + error?: string; + data?: unknown; +} + +export interface IDsmrEvent { + type: TDsmrEventType; + snapshot?: IDsmrSnapshot; + sensor?: IDsmrSensorState; + data?: unknown; + error?: string; + timestamp: number; +} + +export interface IDsmrManualEntry { + id?: string; + name?: string; + host?: string; + port?: number | string; + serialPort?: string; + device?: string; + path?: string; + type?: TDsmrConnectionType | 'serial' | 'network'; + protocol?: string; + dsmrVersion?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; +} diff --git a/ts/integrations/dsmr/index.ts b/ts/integrations/dsmr/index.ts index 03d15ae..38c710f 100644 --- a/ts/integrations/dsmr/index.ts +++ b/ts/integrations/dsmr/index.ts @@ -1,2 +1,8 @@ +export * from './dsmr.classes.client.js'; +export * from './dsmr.classes.configflow.js'; export * from './dsmr.classes.integration.js'; +export * from './dsmr.constants.js'; +export * from './dsmr.discovery.js'; +export * from './dsmr.mapper.js'; +export * from './dsmr.parser.js'; export * from './dsmr.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index b248b04..5307db3 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -21,7 +21,6 @@ import { HomeAssistantAftershipIntegration } from '../aftership/index.js'; import { HomeAssistantAgentDvrIntegration } from '../agent_dvr/index.js'; import { HomeAssistantAiTaskIntegration } from '../ai_task/index.js'; import { HomeAssistantAirQualityIntegration } from '../air_quality/index.js'; -import { HomeAssistantAirgradientIntegration } from '../airgradient/index.js'; import { HomeAssistantAirlyIntegration } from '../airly/index.js'; import { HomeAssistantAirnowIntegration } from '../airnow/index.js'; import { HomeAssistantAirobotIntegration } from '../airobot/index.js'; @@ -53,7 +52,6 @@ import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/ind import { HomeAssistantAmpioIntegration } from '../ampio/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js'; -import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js'; import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js'; @@ -63,7 +61,6 @@ import { HomeAssistantAnthropicIntegration } from '../anthropic/index.js'; import { HomeAssistantAnwbEnergieIntegration } from '../anwb_energie/index.js'; import { HomeAssistantAosmithIntegration } from '../aosmith/index.js'; import { HomeAssistantApacheKafkaIntegration } from '../apache_kafka/index.js'; -import { HomeAssistantApcupsdIntegration } from '../apcupsd/index.js'; import { HomeAssistantApiIntegration } from '../api/index.js'; import { HomeAssistantApolloAutomationIntegration } from '../apollo_automation/index.js'; import { HomeAssistantAppalachianpowerIntegration } from '../appalachianpower/index.js'; @@ -128,7 +125,6 @@ import { HomeAssistantBinarySensorIntegration } from '../binary_sensor/index.js' import { HomeAssistantBitcoinIntegration } from '../bitcoin/index.js'; import { HomeAssistantBizkaibusIntegration } from '../bizkaibus/index.js'; import { HomeAssistantBlackbirdIntegration } from '../blackbird/index.js'; -import { HomeAssistantBleboxIntegration } from '../blebox/index.js'; import { HomeAssistantBlinkIntegration } from '../blink/index.js'; import { HomeAssistantBlinksticklightIntegration } from '../blinksticklight/index.js'; import { HomeAssistantBlissAutomationIntegration } from '../bliss_automation/index.js'; @@ -149,7 +145,6 @@ import { HomeAssistantBrandsIntegration } from '../brands/index.js'; import { HomeAssistantBrandtIntegration } from '../brandt/index.js'; import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js'; import { HomeAssistantBringIntegration } from '../bring/index.js'; -import { HomeAssistantBroadlinkIntegration } from '../broadlink/index.js'; import { HomeAssistantBrotherIntegration } from '../brother/index.js'; import { HomeAssistantBrottsplatskartanIntegration } from '../brottsplatskartan/index.js'; import { HomeAssistantBrowserIntegration } from '../browser/index.js'; @@ -270,7 +265,6 @@ import { HomeAssistantDremel3dPrinterIntegration } from '../dremel_3d_printer/in import { HomeAssistantDropConnectIntegration } from '../drop_connect/index.js'; import { HomeAssistantDropboxIntegration } from '../dropbox/index.js'; import { HomeAssistantDropletIntegration } from '../droplet/index.js'; -import { HomeAssistantDsmrIntegration } from '../dsmr/index.js'; import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js'; import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js'; import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js'; @@ -1441,7 +1435,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAftershipIntegratio generatedHomeAssistantPortIntegrations.push(new HomeAssistantAgentDvrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAiTaskIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirQualityIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirgradientIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirlyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirnowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirobotIntegration()); @@ -1473,7 +1466,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationInte generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); @@ -1483,7 +1475,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnthropicIntegratio generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnwbEnergieIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAosmithIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApacheKafkaIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantApcupsdIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApolloAutomationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAppalachianpowerIntegration()); @@ -1548,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBinarySensorIntegra generatedHomeAssistantPortIntegrations.push(new HomeAssistantBitcoinIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBizkaibusIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlackbirdIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantBleboxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinksticklightIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlissAutomationIntegration()); @@ -1569,7 +1559,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrotherIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrottsplatskartanIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrowserIntegration()); @@ -1690,7 +1679,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDremel3dPrinterInte generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropConnectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropboxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropletIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration()); @@ -2840,15 +2828,21 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1418; +export const generatedHomeAssistantPortCount = 1412; export const handwrittenHomeAssistantPortDomains = [ + "airgradient", + "android_ip_webcam", "androidtv", + "apcupsd", "axis", + "blebox", "braviatv", + "broadlink", "cast", "deconz", "denonavr", "dlna_dmr", + "dsmr", "esphome", "homekit_controller", "homematic",