Add native local device integrations
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -3,13 +3,19 @@ export * from './protocols/index.js';
|
||||
export * from './integrations/index.js';
|
||||
|
||||
import { HueIntegration } from './integrations/hue/index.js';
|
||||
import { AirgradientIntegration } from './integrations/airgradient/index.js';
|
||||
import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js';
|
||||
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
|
||||
import { AxisIntegration } from './integrations/axis/index.js';
|
||||
import { ApcupsdIntegration } from './integrations/apcupsd/index.js';
|
||||
import { BleboxIntegration } from './integrations/blebox/index.js';
|
||||
import { BraviatvIntegration } from './integrations/braviatv/index.js';
|
||||
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
|
||||
import { CastIntegration } from './integrations/cast/index.js';
|
||||
import { DeconzIntegration } from './integrations/deconz/index.js';
|
||||
import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
|
||||
import { DsmrIntegration } from './integrations/dsmr/index.js';
|
||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||
import { HomematicIntegration } from './integrations/homematic/index.js';
|
||||
@@ -47,13 +53,19 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
|
||||
import { IntegrationRegistry } from './core/index.js';
|
||||
|
||||
export const integrations = [
|
||||
new AirgradientIntegration(),
|
||||
new AndroidIpWebcamIntegration(),
|
||||
new AndroidtvIntegration(),
|
||||
new ApcupsdIntegration(),
|
||||
new AxisIntegration(),
|
||||
new BleboxIntegration(),
|
||||
new BraviatvIntegration(),
|
||||
new BroadlinkIntegration(),
|
||||
new CastIntegration(),
|
||||
new DeconzIntegration(),
|
||||
new DenonavrIntegration(),
|
||||
new DlnaDmrIntegration(),
|
||||
new DsmrIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new HomekitControllerIntegration(),
|
||||
new HomematicIntegration(),
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "airgradient",
|
||||
displayName: "AirGradient",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/airgradient",
|
||||
"upstreamDomain": "airgradient",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"airgradient==0.9.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@airgradienthq",
|
||||
"@joostlek"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AirgradientIntegration extends BaseIntegration<IAirgradientConfig> {
|
||||
public readonly domain = 'airgradient';
|
||||
public readonly displayName = 'AirGradient';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAirgradientDiscoveryDescriptor();
|
||||
public readonly configFlow = new AirgradientConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/airgradient',
|
||||
upstreamDomain: 'airgradient',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['airgradient==0.9.2'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@airgradienthq', '@joostlek'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/airgradient',
|
||||
zeroconf: ['_airgradient._tcp.local.'],
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local HTTP snapshot',
|
||||
endpoints: ['GET /measures/current', 'GET /config', 'PUT /config'],
|
||||
services: ['set_config', 'request_co2_calibration', 'request_led_bar_test', 'refresh'],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IAirgradientConfig, contextArg: IIntegrationSetupContext): Promise<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 {
|
||||
// TODO: replace with the TypeScript-native config for airgradient.
|
||||
import type { IServiceCallRequest } from '../../core/types.js';
|
||||
|
||||
export const airgradientDefaultPort = 80;
|
||||
export const airgradientMdnsType = '_airgradient._tcp.local';
|
||||
export const airgradientMinFirmwareVersion = '3.1.1';
|
||||
|
||||
export type TAirgradientProtocol = 'http' | 'https';
|
||||
export type TAirgradientConfigurationControl = 'cloud' | 'local' | 'both';
|
||||
export type TAirgradientPmStandard = 'ugm3' | 'us-aqi';
|
||||
export type TAirgradientDisplayPmStandard = 'ugm3' | 'us_aqi';
|
||||
export type TAirgradientTemperatureUnit = 'c' | 'f';
|
||||
export type TAirgradientLedBarMode = 'off' | 'co2' | 'pm';
|
||||
|
||||
export type TAirgradientConfigField =
|
||||
| 'pmStandard'
|
||||
| 'temperatureUnit'
|
||||
| 'configurationControl'
|
||||
| 'ledBarMode'
|
||||
| 'co2CalibrationRequested'
|
||||
| 'ledBarTestRequested'
|
||||
| 'displayBrightness'
|
||||
| 'ledBarBrightness'
|
||||
| 'postDataToAirGradient'
|
||||
| 'abcDays'
|
||||
| 'noxLearningOffset'
|
||||
| 'tvocLearningOffset';
|
||||
|
||||
export interface IAirgradientConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TAirgradientProtocol;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
serialNumber?: string;
|
||||
model?: string;
|
||||
firmwareVersion?: string;
|
||||
latestFirmwareVersion?: string;
|
||||
connected?: boolean;
|
||||
measures?: IAirgradientMeasures;
|
||||
deviceConfig?: IAirgradientDeviceConfig;
|
||||
deviceInfo?: IAirgradientDeviceInfo;
|
||||
snapshot?: IAirgradientSnapshot;
|
||||
devices?: IAirgradientDevice[];
|
||||
manualEntries?: IAirgradientManualEntry[];
|
||||
events?: IAirgradientEvent[];
|
||||
checkFirmwareUpdate?: boolean;
|
||||
commandExecutor?: TAirgradientCommandExecutor;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantAirgradientConfig extends IAirgradientConfig {}
|
||||
|
||||
export interface IAirgradientDeviceInfo {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TAirgradientProtocol;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelName?: string;
|
||||
serialNumber?: string;
|
||||
firmwareVersion?: string;
|
||||
latestFirmwareVersion?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IAirgradientMeasures {
|
||||
signalStrength?: number;
|
||||
serialNumber?: string;
|
||||
bootTime?: number;
|
||||
firmwareVersion?: string;
|
||||
model?: string;
|
||||
rco2?: number | null;
|
||||
pm01?: number | null;
|
||||
pm02?: number | null;
|
||||
rawPm02?: number | null;
|
||||
compensatedPm02?: number | null;
|
||||
pm10?: number | null;
|
||||
totalVolatileOrganicComponentIndex?: number | null;
|
||||
rawTotalVolatileOrganicComponent?: number | null;
|
||||
pm003Count?: number | null;
|
||||
nitrogenIndex?: number | null;
|
||||
rawNitrogen?: number | null;
|
||||
ambientTemperature?: number | null;
|
||||
rawAmbientTemperature?: number | null;
|
||||
compensatedAmbientTemperature?: number | null;
|
||||
relativeHumidity?: number | null;
|
||||
rawRelativeHumidity?: number | null;
|
||||
compensatedRelativeHumidity?: number | null;
|
||||
raw?: Record<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;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './airgradient.classes.client.js';
|
||||
export * from './airgradient.classes.configflow.js';
|
||||
export * from './airgradient.classes.integration.js';
|
||||
export * from './airgradient.discovery.js';
|
||||
export * from './airgradient.mapper.js';
|
||||
export * from './airgradient.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "android_ip_webcam",
|
||||
displayName: "Android IP Webcam",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/android_ip_webcam",
|
||||
"upstreamDomain": "android_ip_webcam",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pydroid-ipcam==3.0.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@engrbm87"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AndroidIpWebcamIntegration extends BaseIntegration<IAndroidIpWebcamConfig> {
|
||||
public readonly domain = 'android_ip_webcam';
|
||||
public readonly displayName = 'Android IP Webcam';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAndroidIpWebcamDiscoveryDescriptor();
|
||||
public readonly configFlow = new AndroidIpWebcamConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/android_ip_webcam',
|
||||
upstreamDomain: 'android_ip_webcam',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pydroid-ipcam==3.0.0'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@engrbm87'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/android_ip_webcam',
|
||||
nativePort: {
|
||||
snapshotMapping: true,
|
||||
manualHostUrlDiscovery: true,
|
||||
liveHttpCommands: true,
|
||||
liveEvents: false,
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IAndroidIpWebcamConfig, contextArg: IIntegrationSetupContext): Promise<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 {
|
||||
// TODO: replace with the TypeScript-native config for android_ip_webcam.
|
||||
export const androidIpWebcamDefaultPort = 8080;
|
||||
export const androidIpWebcamDefaultTimeoutMs = 10000;
|
||||
|
||||
export type TAndroidIpWebcamProtocol = 'http' | 'https';
|
||||
export type TAndroidIpWebcamRtspVideoCodec = 'jpeg' | 'h264';
|
||||
export type TAndroidIpWebcamRtspAudioCodec = 'ulaw' | 'alaw' | 'pcm' | 'opus' | 'aac';
|
||||
export type TAndroidIpWebcamOrientation = 'landscape' | 'upsidedown' | 'portrait' | 'upsidedown_portrait';
|
||||
export type TAndroidIpWebcamSettingCommand = 'setting' | 'torch' | 'focus' | 'record';
|
||||
export type TAndroidIpWebcamCommandType =
|
||||
| 'snapshot_image'
|
||||
| 'stream_source'
|
||||
| 'setting'
|
||||
| 'torch'
|
||||
| 'focus'
|
||||
| 'record'
|
||||
| 'set_zoom'
|
||||
| 'set_quality'
|
||||
| 'set_orientation'
|
||||
| 'set_scenemode'
|
||||
| 'refresh';
|
||||
|
||||
export interface IAndroidIpWebcamConfig {
|
||||
protocol?: TAndroidIpWebcamProtocol;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
connected?: boolean;
|
||||
deviceInfo?: IAndroidIpWebcamDeviceInfo;
|
||||
camera?: IAndroidIpWebcamCamera;
|
||||
sensors?: IAndroidIpWebcamSensor[];
|
||||
binarySensors?: IAndroidIpWebcamBinarySensor[];
|
||||
switches?: IAndroidIpWebcamSwitch[];
|
||||
statusData?: IAndroidIpWebcamStatusData;
|
||||
sensorData?: IAndroidIpWebcamSensorData;
|
||||
currentSettings?: Record<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;
|
||||
}
|
||||
|
||||
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.discovery.js';
|
||||
export * from './android_ip_webcam.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "apcupsd",
|
||||
displayName: "APC UPS Daemon",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/apcupsd",
|
||||
"upstreamDomain": "apcupsd",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"aioapcaccess==1.0.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@yuxincs"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class ApcupsdIntegration extends BaseIntegration<IApcupsdConfig> {
|
||||
public readonly domain = 'apcupsd';
|
||||
public readonly displayName = 'APC UPS Daemon';
|
||||
public readonly status = 'read-only-runtime' as const;
|
||||
public readonly discoveryDescriptor = createApcupsdDiscoveryDescriptor();
|
||||
public readonly configFlow = new ApcupsdConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/apcupsd',
|
||||
upstreamDomain: 'apcupsd',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['aioapcaccess==1.0.0'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@yuxincs'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/apcupsd',
|
||||
discovery: {
|
||||
manual: true,
|
||||
note: 'Home Assistant APCUPSd has no automatic discovery; manual NIS host/port and snapshot inputs are implemented.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'read-only-runtime',
|
||||
polling: 'local TCP APCUPSd NIS status command',
|
||||
services: ['snapshot', 'status', 'refresh'],
|
||||
controls: false,
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IApcupsdConfig, contextArg: IIntegrationSetupContext): Promise<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());
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,105 @@
|
||||
export interface IHomeAssistantApcupsdConfig {
|
||||
// TODO: replace with the TypeScript-native config for apcupsd.
|
||||
[key: string]: unknown;
|
||||
export const apcupsdDefaultPort = 3551;
|
||||
export const apcupsdDefaultTimeoutMs = 10000;
|
||||
|
||||
export type TApcupsdTransport = 'tcp' | 'snapshot';
|
||||
export type TApcupsdSnapshotSource = 'tcp' | 'manual' | 'snapshot' | 'runtime';
|
||||
|
||||
export interface IApcupsdConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
transport?: TApcupsdTransport;
|
||||
snapshot?: IApcupsdSnapshot;
|
||||
rawStatus?: string | IApcupsdStatusRecord;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantApcupsdConfig extends IApcupsdConfig {}
|
||||
|
||||
export interface IApcupsdStatusRecord {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface IApcupsdUpsInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
firmware?: string;
|
||||
version?: string;
|
||||
hostname?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
statusFlag?: number;
|
||||
lineOnline?: boolean;
|
||||
}
|
||||
|
||||
export interface IApcupsdBatteryInfo {
|
||||
chargePercent?: number;
|
||||
voltage?: number;
|
||||
nominalVoltage?: number;
|
||||
timeLeftMinutes?: number;
|
||||
timeOnBatterySeconds?: number;
|
||||
totalTimeOnBatterySeconds?: number;
|
||||
status?: string;
|
||||
replacementDate?: string;
|
||||
lastSelfTest?: string;
|
||||
selfTest?: string;
|
||||
badBatteries?: number;
|
||||
externalBatteries?: number;
|
||||
}
|
||||
|
||||
export interface IApcupsdPowerInfo {
|
||||
lineVoltage?: number;
|
||||
outputVoltage?: number;
|
||||
loadPercent?: number;
|
||||
loadApparentPercent?: number;
|
||||
lineFrequency?: number;
|
||||
nominalInputVoltage?: number;
|
||||
nominalOutputVoltage?: number;
|
||||
nominalPowerWatts?: number;
|
||||
nominalApparentPowerVa?: number;
|
||||
outputCurrentAmps?: number;
|
||||
inputVoltageHigh?: number;
|
||||
inputVoltageLow?: number;
|
||||
transferHighVoltage?: number;
|
||||
transferLowVoltage?: number;
|
||||
transferCount?: number;
|
||||
lastTransfer?: string;
|
||||
}
|
||||
|
||||
export interface IApcupsdSnapshot {
|
||||
ups: IApcupsdUpsInfo;
|
||||
battery: IApcupsdBatteryInfo;
|
||||
power: IApcupsdPowerInfo;
|
||||
status: IApcupsdStatusRecord;
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
source?: TApcupsdSnapshotSource;
|
||||
raw?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IApcupsdManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
snapshot?: IApcupsdSnapshot;
|
||||
rawStatus?: string | IApcupsdStatusRecord;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IApcupsdDiscoveryRecord {
|
||||
source: 'manual';
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './apcupsd.classes.integration.js';
|
||||
export * from './apcupsd.classes.client.js';
|
||||
export * from './apcupsd.classes.configflow.js';
|
||||
export * from './apcupsd.discovery.js';
|
||||
export * from './apcupsd.mapper.js';
|
||||
export * from './apcupsd.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "blebox",
|
||||
displayName: "BleBox devices",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/blebox",
|
||||
"upstreamDomain": "blebox",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"blebox-uniapi==2.5.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@bbx-a",
|
||||
"@swistakm",
|
||||
"@bkobus-bbx"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class BleboxIntegration extends BaseIntegration<IBleboxConfig> {
|
||||
public readonly domain = 'blebox';
|
||||
public readonly displayName = 'BleBox devices';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createBleboxDiscoveryDescriptor();
|
||||
public readonly configFlow = new BleboxConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/blebox',
|
||||
upstreamDomain: 'blebox',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['blebox-uniapi==2.5.2'],
|
||||
codeowners: ['@bbx-a', '@swistakm', '@bkobus-bbx'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IBleboxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new BleboxRuntime(new BleboxClient(configArg));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,4 +1,175 @@
|
||||
export interface IHomeAssistantBleboxConfig {
|
||||
// TODO: replace with the TypeScript-native config for blebox.
|
||||
[key: string]: unknown;
|
||||
export type TBleboxProtocol = 'http';
|
||||
|
||||
export type TBleboxEntityPlatform = 'switch' | 'light' | 'cover' | 'sensor' | 'binary_sensor';
|
||||
|
||||
export type TBleboxJsonValue = string | number | boolean | null | TBleboxJsonValue[] | {
|
||||
[key: string]: TBleboxJsonValue | undefined;
|
||||
};
|
||||
|
||||
export interface IBleboxConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TBleboxProtocol;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeoutMs?: number;
|
||||
snapshot?: IBleboxSnapshot;
|
||||
}
|
||||
|
||||
export interface IBleboxDeviceInfo {
|
||||
id?: string;
|
||||
type?: string;
|
||||
product?: string;
|
||||
deviceName?: string;
|
||||
fv?: string;
|
||||
hv?: string;
|
||||
apiLevel?: string | number;
|
||||
ip?: string;
|
||||
availableFv?: string | null;
|
||||
[key: string]: TBleboxJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IBleboxProductProfile {
|
||||
productType: string;
|
||||
model: string;
|
||||
apiLevel: number;
|
||||
apiPath: string;
|
||||
extendedStatePath?: string;
|
||||
supports: Partial<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 {}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './blebox.classes.client.js';
|
||||
export * from './blebox.classes.configflow.js';
|
||||
export * from './blebox.classes.integration.js';
|
||||
export * from './blebox.discovery.js';
|
||||
export * from './blebox.mapper.js';
|
||||
export * from './blebox.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "broadlink",
|
||||
displayName: "Broadlink",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/broadlink",
|
||||
"upstreamDomain": "broadlink",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"broadlink==0.19.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@danielhiversen",
|
||||
"@felipediel",
|
||||
"@L-I-Am",
|
||||
"@eifinger"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class BroadlinkIntegration extends BaseIntegration<IBroadlinkConfig> {
|
||||
public readonly domain = 'broadlink';
|
||||
public readonly displayName = 'Broadlink';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createBroadlinkDiscoveryDescriptor();
|
||||
public readonly configFlow = new BroadlinkConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/broadlink',
|
||||
upstreamDomain: 'broadlink',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['broadlink==0.19.0'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@danielhiversen', '@felipediel', '@L-I-Am', '@eifinger'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/broadlink',
|
||||
configFlow: true,
|
||||
discovery: {
|
||||
dhcp: true,
|
||||
manual: true,
|
||||
localHelloRecords: true,
|
||||
note: 'Home Assistant uses python-broadlink UDP hello/auth. This native port recognizes DHCP/manual/local hello candidates; live UDP is an injected executor boundary.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'snapshot/manual',
|
||||
services: ['remote.send_command', 'remote.learn_command', 'remote.delete_command', 'switch.turn_on', 'switch.turn_off', 'infrared.send_command', 'radio_frequency.send_command', 'broadlink.send_packet'],
|
||||
liveUdpAuthImplemented: false,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'Broadlink DHCP/manual/local hello discovery candidate mapping',
|
||||
'Config flow shape for host, MAC, device type, timeout, and optional snapshot JSON',
|
||||
'Snapshot mapping for RM remote entities, Broadlink switch/outlet slots, custom IR/RF switches, and safe sensor keys',
|
||||
'Packet encoding for Broadlink IR timings and 315/433 MHz RF timings',
|
||||
'Explicit service command shapes for learned-code send/delete, learn IR/RF, send packet, switch power, IR, and RF commands',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'Native Broadlink UDP discovery broadcast from this package',
|
||||
'Native Broadlink encrypted UDP auth/session transport',
|
||||
'Claiming live packets were sent without an injected commandExecutor or transport.execute',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IBroadlinkConfig, contextArg: IIntegrationSetupContext): Promise<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';
|
||||
};
|
||||
@@ -1,4 +1,250 @@
|
||||
export interface IHomeAssistantBroadlinkConfig {
|
||||
// TODO: replace with the TypeScript-native config for broadlink.
|
||||
[key: string]: unknown;
|
||||
import type { IServiceCallRequest, IServiceCallResult, TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
export type TBroadlinkDeviceType =
|
||||
| 'A1'
|
||||
| 'A2'
|
||||
| 'BG1'
|
||||
| 'HYS'
|
||||
| 'LB1'
|
||||
| 'LB2'
|
||||
| 'MP1'
|
||||
| 'MP1S'
|
||||
| 'RM4MINI'
|
||||
| 'RM4PRO'
|
||||
| 'RMMINI'
|
||||
| 'RMMINIB'
|
||||
| 'RMPRO'
|
||||
| 'SP1'
|
||||
| 'SP2'
|
||||
| 'SP2S'
|
||||
| 'SP3'
|
||||
| 'SP3S'
|
||||
| 'SP4'
|
||||
| 'SP4B'
|
||||
| string;
|
||||
|
||||
export type TBroadlinkCommandType =
|
||||
| 'delete_command'
|
||||
| 'learn_ir'
|
||||
| 'learn_rf'
|
||||
| 'remote_turn_off'
|
||||
| 'remote_turn_on'
|
||||
| 'send_data'
|
||||
| 'send_ir'
|
||||
| 'send_packet'
|
||||
| 'send_rf'
|
||||
| 'set_power'
|
||||
| 'set_state';
|
||||
|
||||
export type TBroadlinkEventType =
|
||||
| 'command_deleted'
|
||||
| 'command_executed'
|
||||
| 'command_failed'
|
||||
| 'command_mapped'
|
||||
| 'snapshot_refreshed';
|
||||
|
||||
export type TBroadlinkPacketKind = 'ir' | 'rf315' | 'rf433' | 'rf' | 'unknown';
|
||||
|
||||
export interface IBroadlinkPacket {
|
||||
base64: string;
|
||||
hex: string;
|
||||
bytes: number[];
|
||||
byteLength: number;
|
||||
kind: TBroadlinkPacketKind;
|
||||
firstByte?: number;
|
||||
frequency?: number;
|
||||
repeatCount?: number;
|
||||
source?: 'base64' | 'hex' | 'ir_timings' | 'rf_timings';
|
||||
}
|
||||
|
||||
export type TBroadlinkCodeValue = string | string[];
|
||||
|
||||
export type TBroadlinkCodeStore = Record<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;
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export * from './broadlink.classes.client.js';
|
||||
export * from './broadlink.classes.configflow.js';
|
||||
export * from './broadlink.classes.integration.js';
|
||||
export * from './broadlink.constants.js';
|
||||
export * from './broadlink.discovery.js';
|
||||
export * from './broadlink.mapper.js';
|
||||
export * from './broadlink.packet.js';
|
||||
export * from './broadlink.types.js';
|
||||
|
||||
@@ -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,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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "dsmr",
|
||||
displayName: "DSMR Smart Meter",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/dsmr",
|
||||
"upstreamDomain": "dsmr",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"dsmr-parser==1.5.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"usb"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Robbie1221"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class DsmrIntegration extends BaseIntegration<IDsmrConfig> {
|
||||
public readonly domain = dsmrDomain;
|
||||
public readonly displayName = 'DSMR Smart Meter';
|
||||
public readonly status = 'read-only-runtime' as const;
|
||||
public readonly discoveryDescriptor = createDsmrDiscoveryDescriptor();
|
||||
public readonly configFlow = new DsmrConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/dsmr',
|
||||
upstreamDomain: dsmrDomain,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['dsmr-parser==1.5.0'],
|
||||
dependencies: ['usb'],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@Robbie1221'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/dsmr',
|
||||
configFlow: true,
|
||||
discovery: {
|
||||
manual: ['serial', 'network'],
|
||||
usb: 'Configuration supports explicit serial paths; no USB/serial probing is performed here.',
|
||||
note: 'Manual serial/network setup is supported. Live serial transport is not faked; use snapshot, telegram, telegramProvider, or liveRead network refresh.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'read-only-runtime',
|
||||
mode: 'DSMR telegram/status snapshot mapping',
|
||||
entities: ['sensor'],
|
||||
services: ['refresh'],
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual DSMR serial and network meter configuration',
|
||||
'native DSMR P1 telegram parsing for common energy, gas, power, voltage, current, and diagnostic sensors',
|
||||
'status/snapshot mapping to Home Assistant-style sensor entities',
|
||||
'network telegram refresh when explicitly enabled with liveRead',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'fake live serial hardware success without a telegram source',
|
||||
'native serial port transport without an injected telegramProvider',
|
||||
'Home Assistant entity registry migrations',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IDsmrConfig, contextArg: IIntegrationSetupContext): Promise<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,4 +1,192 @@
|
||||
export interface IHomeAssistantDsmrConfig {
|
||||
// TODO: replace with the TypeScript-native config for dsmr.
|
||||
export const dsmrDomain = 'dsmr';
|
||||
export const dsmrDefaultDsmrVersion = '2.2';
|
||||
export const dsmrDefaultNetworkPort = 2001;
|
||||
export const dsmrDefaultTimeoutMs = 30000;
|
||||
export const dsmrDefaultPrecision = 3;
|
||||
|
||||
export type TDsmrConnectionType = 'serial' | 'network';
|
||||
export type TDsmrProtocol = 'dsmr_protocol' | 'rfxtrx_dsmr_protocol';
|
||||
export type TDsmrVersion = '2.2' | '4' | '5' | '5B' | '5L' | '5S' | 'Q3D' | '5EONHU';
|
||||
export type TDsmrDeviceType = 'electricity' | 'gas' | 'water' | 'heat';
|
||||
export type TDsmrSnapshotSource = 'manual' | 'snapshot' | 'status' | 'telegram' | 'network' | 'serial';
|
||||
export type TDsmrStateClass = 'measurement' | 'total' | 'total_increasing';
|
||||
export type TDsmrEventType = 'snapshot_refreshed' | 'telegram_received' | 'refresh_failed' | 'connection_status';
|
||||
export type TDsmrSensorValue = string | number | boolean | null;
|
||||
|
||||
export interface IDsmrConfig {
|
||||
id?: string;
|
||||
name?: string;
|
||||
connectionType?: TDsmrConnectionType;
|
||||
type?: TDsmrConnectionType | 'Serial' | 'Network';
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
serialPort?: string;
|
||||
device?: string;
|
||||
path?: string;
|
||||
dsmrVersion?: TDsmrVersion | string;
|
||||
dsmr_version?: TDsmrVersion | string;
|
||||
protocol?: TDsmrProtocol | string;
|
||||
serialId?: string | null;
|
||||
serial_id?: string | null;
|
||||
serialIdGas?: string | null;
|
||||
serial_id_gas?: string | null;
|
||||
timeBetweenUpdate?: number;
|
||||
time_between_update?: number;
|
||||
connected?: boolean;
|
||||
timeoutMs?: number;
|
||||
liveRead?: boolean;
|
||||
snapshot?: IDsmrSnapshot;
|
||||
status?: IDsmrStatusSnapshot;
|
||||
telegram?: string | IDsmrSnapshot;
|
||||
telegrams?: string[];
|
||||
telegramProvider?: () => Promise<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;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export * from './dsmr.classes.client.js';
|
||||
export * from './dsmr.classes.configflow.js';
|
||||
export * from './dsmr.classes.integration.js';
|
||||
export * from './dsmr.constants.js';
|
||||
export * from './dsmr.discovery.js';
|
||||
export * from './dsmr.mapper.js';
|
||||
export * from './dsmr.parser.js';
|
||||
export * from './dsmr.types.js';
|
||||
|
||||
@@ -21,7 +21,6 @@ import { HomeAssistantAftershipIntegration } from '../aftership/index.js';
|
||||
import { HomeAssistantAgentDvrIntegration } from '../agent_dvr/index.js';
|
||||
import { HomeAssistantAiTaskIntegration } from '../ai_task/index.js';
|
||||
import { HomeAssistantAirQualityIntegration } from '../air_quality/index.js';
|
||||
import { HomeAssistantAirgradientIntegration } from '../airgradient/index.js';
|
||||
import { HomeAssistantAirlyIntegration } from '../airly/index.js';
|
||||
import { HomeAssistantAirnowIntegration } from '../airnow/index.js';
|
||||
import { HomeAssistantAirobotIntegration } from '../airobot/index.js';
|
||||
@@ -53,7 +52,6 @@ import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/ind
|
||||
import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
|
||||
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
|
||||
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
|
||||
import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js';
|
||||
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
|
||||
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
|
||||
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js';
|
||||
@@ -63,7 +61,6 @@ import { HomeAssistantAnthropicIntegration } from '../anthropic/index.js';
|
||||
import { HomeAssistantAnwbEnergieIntegration } from '../anwb_energie/index.js';
|
||||
import { HomeAssistantAosmithIntegration } from '../aosmith/index.js';
|
||||
import { HomeAssistantApacheKafkaIntegration } from '../apache_kafka/index.js';
|
||||
import { HomeAssistantApcupsdIntegration } from '../apcupsd/index.js';
|
||||
import { HomeAssistantApiIntegration } from '../api/index.js';
|
||||
import { HomeAssistantApolloAutomationIntegration } from '../apollo_automation/index.js';
|
||||
import { HomeAssistantAppalachianpowerIntegration } from '../appalachianpower/index.js';
|
||||
@@ -128,7 +125,6 @@ import { HomeAssistantBinarySensorIntegration } from '../binary_sensor/index.js'
|
||||
import { HomeAssistantBitcoinIntegration } from '../bitcoin/index.js';
|
||||
import { HomeAssistantBizkaibusIntegration } from '../bizkaibus/index.js';
|
||||
import { HomeAssistantBlackbirdIntegration } from '../blackbird/index.js';
|
||||
import { HomeAssistantBleboxIntegration } from '../blebox/index.js';
|
||||
import { HomeAssistantBlinkIntegration } from '../blink/index.js';
|
||||
import { HomeAssistantBlinksticklightIntegration } from '../blinksticklight/index.js';
|
||||
import { HomeAssistantBlissAutomationIntegration } from '../bliss_automation/index.js';
|
||||
@@ -149,7 +145,6 @@ import { HomeAssistantBrandsIntegration } from '../brands/index.js';
|
||||
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
|
||||
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
|
||||
import { HomeAssistantBringIntegration } from '../bring/index.js';
|
||||
import { HomeAssistantBroadlinkIntegration } from '../broadlink/index.js';
|
||||
import { HomeAssistantBrotherIntegration } from '../brother/index.js';
|
||||
import { HomeAssistantBrottsplatskartanIntegration } from '../brottsplatskartan/index.js';
|
||||
import { HomeAssistantBrowserIntegration } from '../browser/index.js';
|
||||
@@ -270,7 +265,6 @@ import { HomeAssistantDremel3dPrinterIntegration } from '../dremel_3d_printer/in
|
||||
import { HomeAssistantDropConnectIntegration } from '../drop_connect/index.js';
|
||||
import { HomeAssistantDropboxIntegration } from '../dropbox/index.js';
|
||||
import { HomeAssistantDropletIntegration } from '../droplet/index.js';
|
||||
import { HomeAssistantDsmrIntegration } from '../dsmr/index.js';
|
||||
import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js';
|
||||
import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js';
|
||||
import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js';
|
||||
@@ -1441,7 +1435,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAftershipIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAgentDvrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAiTaskIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirQualityIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirgradientIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirlyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirnowIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirobotIntegration());
|
||||
@@ -1473,7 +1466,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationInte
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
|
||||
@@ -1483,7 +1475,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnthropicIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnwbEnergieIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAosmithIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApacheKafkaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApcupsdIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantApolloAutomationIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAppalachianpowerIntegration());
|
||||
@@ -1548,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBinarySensorIntegra
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBitcoinIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBizkaibusIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlackbirdIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBleboxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlinksticklightIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlissAutomationIntegration());
|
||||
@@ -1569,7 +1559,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrotherIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrottsplatskartanIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrowserIntegration());
|
||||
@@ -1690,7 +1679,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDremel3dPrinterInte
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropConnectIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropboxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDropletIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration());
|
||||
@@ -2840,15 +2828,21 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1418;
|
||||
export const generatedHomeAssistantPortCount = 1412;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"airgradient",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
"apcupsd",
|
||||
"axis",
|
||||
"blebox",
|
||||
"braviatv",
|
||||
"broadlink",
|
||||
"cast",
|
||||
"deconz",
|
||||
"denonavr",
|
||||
"dlna_dmr",
|
||||
"dsmr",
|
||||
"esphome",
|
||||
"homekit_controller",
|
||||
"homematic",
|
||||
|
||||
Reference in New Issue
Block a user