Add native local device integrations

This commit is contained in:
2026-05-05 18:26:11 +00:00
parent accfa82f36
commit 282283d344
69 changed files with 9713 additions and 182 deletions
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
+45
View File
@@ -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<void>((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<void>((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();
@@ -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();
+61
View File
@@ -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();
+37
View File
@@ -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();
+94
View File
@@ -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();
+79
View File
@@ -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();
@@ -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();
@@ -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();
+39
View File
@@ -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();
+73
View File
@@ -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();
+12
View File
@@ -3,13 +3,19 @@ export * from './protocols/index.js';
export * from './integrations/index.js'; export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/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 { AndroidtvIntegration } from './integrations/androidtv/index.js';
import { AxisIntegration } from './integrations/axis/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 { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js'; import { CastIntegration } from './integrations/cast/index.js';
import { DeconzIntegration } from './integrations/deconz/index.js'; import { DeconzIntegration } from './integrations/deconz/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js'; import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/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 { EsphomeIntegration } from './integrations/esphome/index.js';
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
import { HomematicIntegration } from './integrations/homematic/index.js'; import { HomematicIntegration } from './integrations/homematic/index.js';
@@ -47,13 +53,19 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
import { IntegrationRegistry } from './core/index.js'; import { IntegrationRegistry } from './core/index.js';
export const integrations = [ export const integrations = [
new AirgradientIntegration(),
new AndroidIpWebcamIntegration(),
new AndroidtvIntegration(), new AndroidtvIntegration(),
new ApcupsdIntegration(),
new AxisIntegration(), new AxisIntegration(),
new BleboxIntegration(),
new BraviatvIntegration(), new BraviatvIntegration(),
new BroadlinkIntegration(),
new CastIntegration(), new CastIntegration(),
new DeconzIntegration(), new DeconzIntegration(),
new DenonavrIntegration(), new DenonavrIntegration(),
new DlnaDmrIntegration(), new DlnaDmrIntegration(),
new DsmrIntegration(),
new EsphomeIntegration(), new EsphomeIntegration(),
new HomekitControllerIntegration(), new HomekitControllerIntegration(),
new HomematicIntegration(), new HomematicIntegration(),
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<TAirgradientEventHandler>();
constructor(private readonly config: IAirgradientConfig) {}
public async getSnapshot(): Promise<IAirgradientSnapshot> {
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<IAirgradientCommandResult> {
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<IAirgradientSnapshot> {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() });
return snapshot;
}
public async destroy(): Promise<void> {
this.eventHandlers.clear();
}
private async executeCommand(commandArg: IAirgradientCommand): Promise<IAirgradientCommandResult> {
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<IAirgradientSnapshot> {
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<IAirgradientMeasures> {
const raw = await this.getJson<Record<string, unknown>>('measures/current');
return parseMeasures(raw);
}
private async getDeviceConfig(): Promise<IAirgradientDeviceConfig> {
const raw = await this.getJson<Record<string, unknown>>('config');
return parseDeviceConfig(raw);
}
private async putConfig(payloadArg: Partial<Record<TAirgradientConfigField, unknown>>): Promise<void> {
await this.requestJson('config', { method: 'PUT', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(payloadArg) });
}
private async getLatestFirmwareVersion(serialNumberArg: string): Promise<string> {
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<Record<string, unknown>>(response, url);
const targetVersion = stringValue(raw.targetVersion);
if (!targetVersion) {
throw new AirgradientParseError('AirGradient firmware version response is missing targetVersion.');
}
return targetVersion;
}
private async getJson<TValue>(pathArg: string): Promise<TValue> {
return this.requestJson<TValue>(pathArg, { method: 'GET', headers: { accept: 'application/json' } });
}
private async requestJson<TValue = unknown>(pathArg: string, initArg: RequestInit): Promise<TValue> {
const url = this.url(pathArg);
const response = await this.fetchWithTimeout(url, initArg);
return this.jsonResponse<TValue>(response, url);
}
private async jsonResponse<TValue>(responseArg: Response, urlArg: string): Promise<TValue> {
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<Response> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown>;
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<string, unknown>;
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, ...keysArg: string[]): unknown => {
for (const key of keysArg) {
if (recordArg[key] !== undefined) {
return recordArg[key];
}
}
return undefined;
};
const stringAlias = (recordArg: Record<string, unknown>, ...keysArg: string[]): string | undefined => stringValue(valueAlias(recordArg, ...keysArg));
const numberAlias = (recordArg: Record<string, unknown>, ...keysArg: string[]): number | undefined => numberValue(valueAlias(recordArg, ...keysArg));
const booleanAlias = (recordArg: Record<string, unknown>, ...keysArg: string[]): boolean | undefined => {
const value = valueAlias(recordArg, ...keysArg);
return typeof value === 'boolean' ? value : undefined;
};
const nullableNumberAlias = (recordArg: Record<string, unknown>, ...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;
@@ -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<IAirgradientConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAirgradientConfig>> {
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<string, unknown> | undefined {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
}
@@ -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 { export class AirgradientIntegration extends BaseIntegration<IAirgradientConfig> {
constructor() { public readonly domain = 'airgradient';
super({ public readonly displayName = 'AirGradient';
domain: "airgradient", public readonly status = 'control-runtime' as const;
displayName: "AirGradient", public readonly discoveryDescriptor = createAirgradientDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AirgradientConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/airgradient", upstreamPath: 'homeassistant/components/airgradient',
"upstreamDomain": "airgradient", upstreamDomain: 'airgradient',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"qualityScale": "platinum", qualityScale: 'platinum',
"requirements": [ requirements: ['airgradient==0.9.2'],
"airgradient==0.9.2" dependencies: [] as string[],
], afterDependencies: [] as string[],
"dependencies": [], codeowners: ['@airgradienthq', '@joostlek'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/airgradient',
"@airgradienthq", zeroconf: ['_airgradient._tcp.local.'],
"@joostlek" 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<IIntegrationRuntime> {
void contextArg;
return new AirgradientRuntime(new AirgradientClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAirgradientIntegration extends AirgradientIntegration {}
class AirgradientRuntime implements IIntegrationRuntime {
public domain = 'airgradient';
constructor(private readonly client: AirgradientClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AirgradientMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AirgradientMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AirgradientMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IAirgradientMdnsRecord> {
public id = 'airgradient-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize AirGradient zeroconf advertisements.';
public async matches(recordArg: IAirgradientMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
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<IAirgradientManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, string | undefined>): Record<string, string | undefined> => {
const normalized: Record<string, string | undefined> = {};
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'));
@@ -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<string, number>();
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<string, number>, attributesArg: Record<string, unknown>, 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<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
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;
};
@@ -1,4 +1,214 @@
export interface IHomeAssistantAirgradientConfig { import type { IServiceCallRequest } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for airgradient.
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<string, unknown>;
[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<string, unknown>;
[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<string, unknown>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IAirgradientSnapshot {
connected: boolean;
updatedAt: string;
devices: IAirgradientDevice[];
events: IAirgradientEvent[];
raw?: Record<string, unknown>;
}
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<Record<TAirgradientConfigField, unknown>>;
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> | 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<string, unknown>;
[key: string]: unknown;
}
export interface IAirgradientMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
[key: string]: unknown; [key: string]: unknown;
} }
+4
View File
@@ -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.classes.integration.js';
export * from './airgradient.discovery.js';
export * from './airgradient.mapper.js';
export * from './airgradient.types.js'; export * from './airgradient.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<TAndroidIpWebcamOrientation>(['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<IAndroidIpWebcamSnapshot> {
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<void> {
await this.fetchSnapshot();
}
public async execute(commandArg: IAndroidIpWebcamClientCommand): Promise<unknown> {
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<IAndroidIpWebcamSnapshotImage> {
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<void> {}
private async fetchSnapshot(): Promise<IAndroidIpWebcamSnapshot> {
const [statusData, sensorData] = await Promise.all([
this.requestJson<IAndroidIpWebcamStatusData>('/status.json?show_avail=1'),
this.requestJson<IAndroidIpWebcamSensorData>('/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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(valuesArg).map(([keyArg, valueArg]) => [keyArg, this.settingValue(valueArg)]));
}
private availableSettings(valuesArg: Record<string, unknown[]>): Record<string, unknown[]> {
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<void> {
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<void> {
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<void> {
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<TValue>(pathArg: string): Promise<TValue> {
const value = await (await this.request(pathArg)).json();
return record(value) ? value as TValue : {} as TValue;
}
private async request(pathArg: string): Promise<Response> {
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<Response> {
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<string, unknown> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
};
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
@@ -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<IAndroidIpWebcamConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAndroidIpWebcamConfig>> {
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;
}
};
@@ -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 { export class AndroidIpWebcamIntegration extends BaseIntegration<IAndroidIpWebcamConfig> {
constructor() { public readonly domain = 'android_ip_webcam';
super({ public readonly displayName = 'Android IP Webcam';
domain: "android_ip_webcam", public readonly status = 'control-runtime' as const;
displayName: "Android IP Webcam", public readonly discoveryDescriptor = createAndroidIpWebcamDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AndroidIpWebcamConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/android_ip_webcam", upstreamPath: 'homeassistant/components/android_ip_webcam',
"upstreamDomain": "android_ip_webcam", upstreamDomain: 'android_ip_webcam',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['pydroid-ipcam==3.0.0'],
"pydroid-ipcam==3.0.0" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@engrbm87'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/android_ip_webcam',
"@engrbm87" nativePort: {
] snapshotMapping: true,
}, manualHostUrlDiscovery: true,
}); liveHttpCommands: true,
liveEvents: false,
},
};
public async setup(configArg: IAndroidIpWebcamConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new AndroidIpWebcamRuntime(new AndroidIpWebcamClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAndroidIpWebcamIntegration extends AndroidIpWebcamIntegration {}
class AndroidIpWebcamRuntime implements IIntegrationRuntime {
public domain = 'android_ip_webcam';
constructor(private readonly client: AndroidIpWebcamClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AndroidIpWebcamMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AndroidIpWebcamMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IAndroidIpWebcamManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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;
}
};
@@ -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<string, number>();
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<string, number>): 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<string, number>, attributesArg: Record<string, unknown>, 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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
}
return String(valueArg);
}
private static booleanFromData(dataArg: Record<string, unknown> | 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';
}
}
@@ -1,4 +1,222 @@
export interface IHomeAssistantAndroidIpWebcamConfig { export const androidIpWebcamDefaultPort = 8080;
// TODO: replace with the TypeScript-native config for android_ip_webcam. 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<string, unknown>;
enabledSensors?: string[];
enabledSettings?: string[];
availableSettings?: Record<string, unknown[]>;
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<string, unknown>;
}
export interface IAndroidIpWebcamSensor<TValue = unknown> {
key: string;
name: string;
value: TValue;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAndroidIpWebcamBinarySensor {
key: string;
name: string;
isOn: boolean;
deviceClass?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAndroidIpWebcamSwitch {
key: string;
name: string;
isOn: boolean;
command: TAndroidIpWebcamSettingCommand;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAndroidIpWebcamStatusData {
curvals?: Record<string, unknown>;
avail?: Record<string, unknown[]>;
audio_connections?: unknown;
video_connections?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
export interface IAndroidIpWebcamSensorDatum {
unit?: string;
data?: unknown;
[key: string]: unknown;
}
export type IAndroidIpWebcamSensorData = Record<string, IAndroidIpWebcamSensorDatum>;
export interface IAndroidIpWebcamSnapshot {
deviceInfo: IAndroidIpWebcamDeviceInfo;
camera: IAndroidIpWebcamCamera;
sensors: IAndroidIpWebcamSensor[];
binarySensors: IAndroidIpWebcamBinarySensor[];
switches: IAndroidIpWebcamSwitch[];
statusData: IAndroidIpWebcamStatusData;
sensorData: IAndroidIpWebcamSensorData;
currentSettings: Record<string, unknown>;
enabledSensors: string[];
enabledSettings: string[];
availableSettings: Record<string, unknown[]>;
connected: boolean;
updatedAt?: string;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
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<string, unknown>;
}
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' },
];
@@ -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.classes.integration.js';
export * from './android_ip_webcam.discovery.js';
export * from './android_ip_webcam.mapper.js';
export * from './android_ip_webcam.types.js'; export * from './android_ip_webcam.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<IApcupsdSnapshot> {
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<IApcupsdSnapshot> {
return this.getSnapshot();
}
public async ping(): Promise<boolean> {
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<void> {}
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<string> {
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<string>((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;
}
}
@@ -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<IApcupsdConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IApcupsdConfig>> {
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;
}
}
@@ -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 { export class ApcupsdIntegration extends BaseIntegration<IApcupsdConfig> {
constructor() { public readonly domain = 'apcupsd';
super({ public readonly displayName = 'APC UPS Daemon';
domain: "apcupsd", public readonly status = 'read-only-runtime' as const;
displayName: "APC UPS Daemon", public readonly discoveryDescriptor = createApcupsdDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new ApcupsdConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/apcupsd", upstreamPath: 'homeassistant/components/apcupsd',
"upstreamDomain": "apcupsd", upstreamDomain: 'apcupsd',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"qualityScale": "platinum", qualityScale: 'platinum',
"requirements": [ requirements: ['aioapcaccess==1.0.0'],
"aioapcaccess==1.0.0" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@yuxincs'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/apcupsd',
"@yuxincs" 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<IIntegrationRuntime> {
void contextArg;
return new ApcupsdRuntime(new ApcupsdClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantApcupsdIntegration extends ApcupsdIntegration {}
class ApcupsdRuntime implements IIntegrationRuntime {
public domain = 'apcupsd';
constructor(private readonly client: ApcupsdClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ApcupsdMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ApcupsdMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IApcupsdManualEntry> {
public id = 'apcupsd-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual APCUPSd NIS setup entries.';
public async matches(inputArg: IApcupsdManualEntry): Promise<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
+152
View File
@@ -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<string, unknown> = {}): 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<string, unknown> = {}): 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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
}
+104 -3
View File
@@ -1,4 +1,105 @@
export interface IHomeAssistantApcupsdConfig { export const apcupsdDefaultPort = 3551;
// TODO: replace with the TypeScript-native config for apcupsd. export const apcupsdDefaultTimeoutMs = 10000;
[key: string]: unknown;
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<string, unknown>;
}
export interface IApcupsdDiscoveryRecord {
source: 'manual';
host?: string;
port?: number;
name?: string;
id?: string;
} }
+4
View File
@@ -1,2 +1,6 @@
export * from './apcupsd.classes.integration.js'; 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'; export * from './apcupsd.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<string, IBleboxProfileTemplate[]> = {
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<IBleboxSnapshot> {
if (this.config.snapshot) {
return this.config.snapshot;
}
return this.identifyAndReadState();
}
public async identifyAndReadState(): Promise<IBleboxSnapshot> {
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<void> {
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<void> {
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<void> {
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<void> {}
private async fetchInitialInfo(): Promise<TBleboxJsonValue> {
try {
return await this.requestJson('/api/device/state');
} catch {
return this.requestJson('/info');
}
}
private async commandGet(pathArg: string): Promise<void> {
await this.requestJson(pathArg);
}
private async requestJson(pathArg: string, optionsArg: { method?: 'GET' | 'POST'; body?: unknown } = {}): Promise<TBleboxJsonValue> {
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<string, string> = {};
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<TBleboxJsonValue>;
}
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<string, TBleboxJsonValue | undefined> | 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<string, TBleboxJsonValue | undefined> | undefined => {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
? valueArg as Record<string, TBleboxJsonValue | undefined>
: undefined;
};
@@ -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<IBleboxConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBleboxConfig>> {
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;
};
@@ -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 { export class BleboxIntegration extends BaseIntegration<IBleboxConfig> {
constructor() { public readonly domain = 'blebox';
super({ public readonly displayName = 'BleBox devices';
domain: "blebox", public readonly status = 'control-runtime' as const;
displayName: "BleBox devices", public readonly discoveryDescriptor = createBleboxDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new BleboxConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/blebox", upstreamPath: 'homeassistant/components/blebox',
"upstreamDomain": "blebox", upstreamDomain: 'blebox',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['blebox-uniapi==2.5.2'],
"blebox-uniapi==2.5.2" codeowners: ['@bbx-a', '@swistakm', '@bkobus-bbx'],
], };
"dependencies": [],
"afterDependencies": [], public async setup(configArg: IBleboxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
"codeowners": [ void contextArg;
"@bbx-a", return new BleboxRuntime(new BleboxClient(configArg));
"@swistakm", }
"@bkobus-bbx"
] public async destroy(): Promise<void> {}
}, }
});
export class HomeAssistantBleboxIntegration extends BleboxIntegration {}
class BleboxRuntime implements IIntegrationRuntime {
public domain = 'blebox';
constructor(private readonly client: BleboxClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return BleboxMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BleboxMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
}
private async callSwitchService(snapshotArg: IBleboxSnapshot, requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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;
} }
} }
+143
View File
@@ -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<IBleboxMdnsRecord> {
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<IDiscoveryMatch> {
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<IBleboxManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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;
};
+577
View File
@@ -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<string, { unit?: string; deviceClass?: string; scale?: number }> = {
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<string, TBleboxJsonValue | undefined> {
const state = asRecord(snapshotArg.state) || {};
const extendedState = asRecord(snapshotArg.extendedState) || {};
const rawInfo = asRecord(snapshotArg.rawInfo) || {};
return { ...rawInfo, ...state, ...extendedState };
}
private static relayStates(rootArg: Record<string, TBleboxJsonValue | undefined> | 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<string, TBleboxJsonValue | undefined> => 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<string, TBleboxJsonValue | undefined> => 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<string, TBleboxJsonValue | undefined>): 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<number, string>)[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<string, TBleboxJsonValue | undefined> | undefined => {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
? valueArg as Record<string, TBleboxJsonValue | undefined>
: 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;
};
+174 -3
View File
@@ -1,4 +1,175 @@
export interface IHomeAssistantBleboxConfig { export type TBleboxProtocol = 'http';
// TODO: replace with the TypeScript-native config for blebox.
[key: string]: unknown; 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<Record<TBleboxEntityPlatform, boolean>>;
}
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<string, string | undefined>;
}
export interface IBleboxManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
model?: string;
manufacturer?: string;
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantBleboxConfig extends IBleboxConfig {}
+4
View File
@@ -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.classes.integration.js';
export * from './blebox.discovery.js';
export * from './blebox.mapper.js';
export * from './blebox.types.js'; export * from './blebox.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<TBroadlinkEventHandler>();
constructor(private readonly config: IBroadlinkConfig) {}
public async getSnapshot(): Promise<IBroadlinkSnapshot> {
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<IBroadlinkSnapshot> {
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<IBroadlinkCommandResult> {
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<void> {
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.';
}
}
@@ -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<IBroadlinkConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBroadlinkConfig>> {
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<string, unknown>): Promise<IConfigFlowStep<IBroadlinkConfig>> {
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;
}
}
@@ -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 { export class BroadlinkIntegration extends BaseIntegration<IBroadlinkConfig> {
constructor() { public readonly domain = 'broadlink';
super({ public readonly displayName = 'Broadlink';
domain: "broadlink", public readonly status = 'control-runtime' as const;
displayName: "Broadlink", public readonly discoveryDescriptor = createBroadlinkDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new BroadlinkConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/broadlink", upstreamPath: 'homeassistant/components/broadlink',
"upstreamDomain": "broadlink", upstreamDomain: 'broadlink',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['broadlink==0.19.0'],
"broadlink==0.19.0" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@danielhiversen', '@felipediel', '@L-I-Am', '@eifinger'],
"afterDependencies": [], documentation: 'https://www.home-assistant.io/integrations/broadlink',
"codeowners": [ configFlow: true,
"@danielhiversen", discovery: {
"@felipediel", dhcp: true,
"@L-I-Am", manual: true,
"@eifinger" 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<IIntegrationRuntime> {
void contextArg;
return new BroadlinkRuntime(new BroadlinkClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantBroadlinkIntegration extends BroadlinkIntegration {}
class BroadlinkRuntime implements IIntegrationRuntime {
public domain = 'broadlink';
constructor(private readonly client: BroadlinkClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return BroadlinkMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BroadlinkMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(BroadlinkMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<TBroadlinkDeviceType>(['RM4MINI', 'RM4PRO', 'RMMINI', 'RMMINIB', 'RMPRO']);
export const broadlinkRfRemoteTypes = new Set<TBroadlinkDeviceType>(['RM4PRO', 'RMPRO']);
export const broadlinkSwitchTypes = new Set<TBroadlinkDeviceType>(['BG1', 'MP1', 'MP1S', 'SP1', 'SP2', 'SP2S', 'SP3', 'SP3S', 'SP4', 'SP4B']);
export const broadlinkSensorTypes = new Set<TBroadlinkDeviceType>(['A1', 'A2', 'MP1S', 'RM4MINI', 'RM4PRO', 'RMPRO', 'SP2S', 'SP3S', 'SP4', 'SP4B']);
export const broadlinkSupportedTypes = new Set<TBroadlinkDeviceType>([
...broadlinkRemoteTypes,
...broadlinkSwitchTypes,
...broadlinkSensorTypes,
'HYS',
'LB1',
'LB2',
]);
export const broadlinkSensorDescriptions: Record<string, { name: string; unit?: string; deviceClass?: string; stateClass?: string }> = {
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' },
};
@@ -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<IBroadlinkDhcpRecord> {
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<IDiscoveryMatch> {
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<IBroadlinkLocalDiscoveryRecord> {
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<IDiscoveryMatch> {
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<IBroadlinkManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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;
};
@@ -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<string, number>();
const seen = new Set<string>();
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<string, number>): 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<typeof BroadlinkMapper.sensorProperties>[number], usedIdsArg: Map<string, number>): 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<string, number>, attributesArg?: Record<string, unknown>, 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<string>();
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<string, unknown>): 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<string, unknown>): IBroadlinkPacket | undefined {
const timings = this.timingsFromData(dataArg);
return timings?.length ? broadlinkIrPacketFromTimings(timings) : undefined;
}
private static rfPacketFromData(dataArg?: Record<string, unknown>): 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<string, unknown>): 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<string, unknown> {
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, number>): 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<string>();
return featuresArg.filter((featureArg) => {
if (seen.has(featureArg.id)) {
return false;
}
seen.add(featureArg.id);
return true;
});
}
private static cleanAttributes(valueArg: Record<string, unknown>): Record<string, unknown> {
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<string, unknown> | 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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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';
};
+249 -3
View File
@@ -1,4 +1,250 @@
export interface IHomeAssistantBroadlinkConfig { import type { IServiceCallRequest, IServiceCallResult, TEntityPlatform } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for broadlink.
[key: string]: unknown; 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<string, Record<string, TBroadlinkCodeValue>>;
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<string, unknown>;
}
export interface IBroadlinkEntityDescriptor {
id?: string;
entityId?: string;
uniqueId?: string;
deviceId?: string;
platform?: TEntityPlatform | 'remote' | string;
name?: string;
state?: unknown;
attributes?: Record<string, unknown>;
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<string, unknown>;
sensors?: Record<string, unknown>;
codes?: TBroadlinkCodeStore;
flags?: Record<string, number>;
switches?: IBroadlinkCustomSwitchConfig[];
supportsRf?: boolean;
updatedAt?: string;
metadata?: Record<string, unknown>;
}
export interface IBroadlinkTransport {
execute(commandArg: IBroadlinkCommand): Promise<IBroadlinkCommandResult | unknown>;
}
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<string, unknown>;
sensors?: Record<string, unknown>;
codes?: TBroadlinkCodeStore;
flags?: Record<string, number>;
switches?: IBroadlinkCustomSwitchConfig[];
devices?: IBroadlinkDevice[];
entities?: IBroadlinkEntityDescriptor[];
manualEntries?: IBroadlinkManualEntry[];
snapshot?: IBroadlinkSnapshot;
metadata?: Record<string, unknown>;
commandExecutor?: (commandArg: IBroadlinkCommand) => Promise<IBroadlinkCommandResult | unknown>;
transport?: IBroadlinkTransport;
}
export interface IBroadlinkSnapshot {
connected: boolean;
host?: string;
port?: number;
devices: IBroadlinkDevice[];
entities: IBroadlinkEntityDescriptor[];
events: IBroadlinkEvent[];
metadata?: Record<string, unknown>;
}
export interface IBroadlinkCommand {
type: TBroadlinkCommandType;
method: string;
service: string;
deviceId?: string;
entityId?: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
payload?: Record<string, unknown>;
packet?: IBroadlinkPacket;
packets?: IBroadlinkPacket[];
commandNames?: string[];
remoteDevice?: string;
commandType?: 'ir' | 'rf';
alternative?: boolean;
numRepeats?: number;
delaySecs?: number;
transmitted?: boolean;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
export interface IBroadlinkManualEntry extends IBroadlinkLocalDiscoveryRecord {
id?: string;
deviceId?: string;
timeout?: number;
state?: Record<string, unknown>;
sensors?: Record<string, unknown>;
codes?: TBroadlinkCodeStore;
switches?: IBroadlinkCustomSwitchConfig[];
device?: IBroadlinkDevice;
devices?: IBroadlinkDevice[];
snapshot?: IBroadlinkSnapshot;
integrationDomain?: string;
}
export interface IBroadlinkCandidateMetadata extends Record<string, unknown> {
broadlink?: boolean;
deviceType?: TBroadlinkDeviceType;
devtype?: number;
isLocked?: boolean;
timeout?: number;
snapshot?: IBroadlinkSnapshot;
}
export type IHomeAssistantBroadlinkConfig = IBroadlinkConfig;
+6
View File
@@ -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.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'; export * from './broadlink.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+183
View File
@@ -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<TDsmrEventHandler>();
constructor(private readonly config: IDsmrConfig) {}
public async getSnapshot(): Promise<IDsmrSnapshot> {
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<IDsmrRefreshResult> {
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<string> {
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<string>((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<void> {
this.eventHandlers.clear();
}
private async initialSnapshot(): Promise<IDsmrSnapshot> {
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<IDsmrSnapshot | undefined> {
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<T extends IDsmrSnapshot>(snapshotArg: T): T {
return JSON.parse(JSON.stringify(snapshotArg)) as T;
}
}
@@ -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<IDsmrConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDsmrConfig>> {
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';
}
}
@@ -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 { export class DsmrIntegration extends BaseIntegration<IDsmrConfig> {
constructor() { public readonly domain = dsmrDomain;
super({ public readonly displayName = 'DSMR Smart Meter';
domain: "dsmr", public readonly status = 'read-only-runtime' as const;
displayName: "DSMR Smart Meter", public readonly discoveryDescriptor = createDsmrDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new DsmrConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/dsmr", upstreamPath: 'homeassistant/components/dsmr',
"upstreamDomain": "dsmr", upstreamDomain: dsmrDomain,
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_push", iotClass: 'local_push',
"requirements": [ requirements: ['dsmr-parser==1.5.0'],
"dsmr-parser==1.5.0" dependencies: ['usb'],
], afterDependencies: [],
"dependencies": [ codeowners: ['@Robbie1221'],
"usb" documentation: 'https://www.home-assistant.io/integrations/dsmr',
], configFlow: true,
"afterDependencies": [], discovery: {
"codeowners": [ manual: ['serial', 'network'],
"@Robbie1221" 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<IIntegrationRuntime> {
void contextArg;
return new DsmrRuntime(new DsmrClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDsmrIntegration extends DsmrIntegration {}
class DsmrRuntime implements IIntegrationRuntime {
public domain = dsmrDomain;
constructor(private readonly client: DsmrClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DsmrMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DsmrMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(DsmrMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+79
View File
@@ -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<string, string> = {
'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' },
];
+141
View File
@@ -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<IDsmrManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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;
+143
View File
@@ -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<string, number>();
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, number>): 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<string, number>): 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<string, unknown>): Record<string, unknown> {
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;
}
}
+533
View File
@@ -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<Omit<IDsmrConfig, 'protocol' | 'dsmrVersion' | 'connectionType' | 'port'>> & {
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<number, number>();
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<number, IDsmrMbusDeviceSnapshot>();
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<string, unknown> } {
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);
+190 -2
View File
@@ -1,4 +1,192 @@
export interface IHomeAssistantDsmrConfig { export const dsmrDomain = 'dsmr';
// TODO: replace with the TypeScript-native config for 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<string | IDsmrSnapshot | IDsmrStatusSnapshot | undefined>;
}
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<string, unknown>;
}
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<string, unknown>;
}
export interface IDsmrStatusValueObject {
value?: TDsmrSensorValue | undefined;
unit?: string;
rawValue?: string;
available?: boolean;
updatedAt?: string;
attributes?: Record<string, unknown>;
}
export interface IDsmrStatusSnapshot {
meter?: Partial<IDsmrMeterInfo>;
sensors?: IDsmrSensorState[];
values?: Record<string, TDsmrSensorValue | IDsmrStatusValueObject | undefined>;
connected?: boolean;
updatedAt?: string;
raw?: Record<string, unknown>;
[key: string]: unknown; [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<string, unknown>;
}
+6
View File
@@ -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.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'; export * from './dsmr.types.js';
+7 -13
View File
@@ -21,7 +21,6 @@ import { HomeAssistantAftershipIntegration } from '../aftership/index.js';
import { HomeAssistantAgentDvrIntegration } from '../agent_dvr/index.js'; import { HomeAssistantAgentDvrIntegration } from '../agent_dvr/index.js';
import { HomeAssistantAiTaskIntegration } from '../ai_task/index.js'; import { HomeAssistantAiTaskIntegration } from '../ai_task/index.js';
import { HomeAssistantAirQualityIntegration } from '../air_quality/index.js'; import { HomeAssistantAirQualityIntegration } from '../air_quality/index.js';
import { HomeAssistantAirgradientIntegration } from '../airgradient/index.js';
import { HomeAssistantAirlyIntegration } from '../airly/index.js'; import { HomeAssistantAirlyIntegration } from '../airly/index.js';
import { HomeAssistantAirnowIntegration } from '../airnow/index.js'; import { HomeAssistantAirnowIntegration } from '../airnow/index.js';
import { HomeAssistantAirobotIntegration } from '../airobot/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 { HomeAssistantAmpioIntegration } from '../ampio/index.js';
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/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 { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/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 { HomeAssistantAnwbEnergieIntegration } from '../anwb_energie/index.js';
import { HomeAssistantAosmithIntegration } from '../aosmith/index.js'; import { HomeAssistantAosmithIntegration } from '../aosmith/index.js';
import { HomeAssistantApacheKafkaIntegration } from '../apache_kafka/index.js'; import { HomeAssistantApacheKafkaIntegration } from '../apache_kafka/index.js';
import { HomeAssistantApcupsdIntegration } from '../apcupsd/index.js';
import { HomeAssistantApiIntegration } from '../api/index.js'; import { HomeAssistantApiIntegration } from '../api/index.js';
import { HomeAssistantApolloAutomationIntegration } from '../apollo_automation/index.js'; import { HomeAssistantApolloAutomationIntegration } from '../apollo_automation/index.js';
import { HomeAssistantAppalachianpowerIntegration } from '../appalachianpower/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 { HomeAssistantBitcoinIntegration } from '../bitcoin/index.js';
import { HomeAssistantBizkaibusIntegration } from '../bizkaibus/index.js'; import { HomeAssistantBizkaibusIntegration } from '../bizkaibus/index.js';
import { HomeAssistantBlackbirdIntegration } from '../blackbird/index.js'; import { HomeAssistantBlackbirdIntegration } from '../blackbird/index.js';
import { HomeAssistantBleboxIntegration } from '../blebox/index.js';
import { HomeAssistantBlinkIntegration } from '../blink/index.js'; import { HomeAssistantBlinkIntegration } from '../blink/index.js';
import { HomeAssistantBlinksticklightIntegration } from '../blinksticklight/index.js'; import { HomeAssistantBlinksticklightIntegration } from '../blinksticklight/index.js';
import { HomeAssistantBlissAutomationIntegration } from '../bliss_automation/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 { HomeAssistantBrandtIntegration } from '../brandt/index.js';
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js'; import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
import { HomeAssistantBringIntegration } from '../bring/index.js'; import { HomeAssistantBringIntegration } from '../bring/index.js';
import { HomeAssistantBroadlinkIntegration } from '../broadlink/index.js';
import { HomeAssistantBrotherIntegration } from '../brother/index.js'; import { HomeAssistantBrotherIntegration } from '../brother/index.js';
import { HomeAssistantBrottsplatskartanIntegration } from '../brottsplatskartan/index.js'; import { HomeAssistantBrottsplatskartanIntegration } from '../brottsplatskartan/index.js';
import { HomeAssistantBrowserIntegration } from '../browser/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 { HomeAssistantDropConnectIntegration } from '../drop_connect/index.js';
import { HomeAssistantDropboxIntegration } from '../dropbox/index.js'; import { HomeAssistantDropboxIntegration } from '../dropbox/index.js';
import { HomeAssistantDropletIntegration } from '../droplet/index.js'; import { HomeAssistantDropletIntegration } from '../droplet/index.js';
import { HomeAssistantDsmrIntegration } from '../dsmr/index.js';
import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js'; import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js';
import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js'; import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js';
import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js'; import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js';
@@ -1441,7 +1435,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAftershipIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAgentDvrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAgentDvrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAiTaskIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAiTaskIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirQualityIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirQualityIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirgradientIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirlyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirlyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirnowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirnowIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirobotIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirobotIntegration());
@@ -1473,7 +1466,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationInte
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
@@ -1483,7 +1475,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnthropicIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnwbEnergieIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnwbEnergieIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAosmithIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAosmithIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApacheKafkaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApacheKafkaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApcupsdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApolloAutomationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantApolloAutomationIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAppalachianpowerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAppalachianpowerIntegration());
@@ -1548,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBinarySensorIntegra
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBitcoinIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBitcoinIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBizkaibusIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBizkaibusIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlackbirdIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlackbirdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBleboxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinksticklightIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinksticklightIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlissAutomationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlissAutomationIntegration());
@@ -1569,7 +1559,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrotherIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrotherIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrottsplatskartanIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrottsplatskartanIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrowserIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrowserIntegration());
@@ -1690,7 +1679,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDremel3dPrinterInte
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropConnectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropConnectIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropboxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropboxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropletIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropletIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration());
@@ -2840,15 +2828,21 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1418; export const generatedHomeAssistantPortCount = 1412;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"airgradient",
"android_ip_webcam",
"androidtv", "androidtv",
"apcupsd",
"axis", "axis",
"blebox",
"braviatv", "braviatv",
"broadlink",
"cast", "cast",
"deconz", "deconz",
"denonavr", "denonavr",
"dlna_dmr", "dlna_dmr",
"dsmr",
"esphome", "esphome",
"homekit_controller", "homekit_controller",
"homematic", "homematic",