Add native local device integrations

This commit is contained in:
2026-05-05 18:26:11 +00:00
parent accfa82f36
commit 282283d344
69 changed files with 9713 additions and 182 deletions
@@ -0,0 +1,62 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createAirgradientDiscoveryDescriptor } from '../../ts/integrations/airgradient/index.js';
tap.test('matches AirGradient zeroconf records', async () => {
const descriptor = createAirgradientDiscoveryDescriptor();
const mdnsMatcher = descriptor.getMatchers()[0];
const result = await mdnsMatcher.matches({
type: '_airgradient._tcp.local.',
name: 'Living Room._airgradient._tcp.local.',
host: 'airgradient-123456.local',
port: 80,
txt: {
serialno: 'abcdef123456',
model: 'I-9PSL',
fw_ver: '3.1.2',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.normalizedDeviceId).toEqual('abcdef123456');
expect(result.candidate?.integrationDomain).toEqual('airgradient');
expect(result.candidate?.metadata?.firmwareSupported).toBeTrue();
});
tap.test('matches manual AirGradient entries and validates supported firmware', async () => {
const descriptor = createAirgradientDiscoveryDescriptor();
const manualMatcher = descriptor.getMatchers()[1];
const result = await manualMatcher.matches({
host: '192.168.1.75',
name: 'Living Room AirGradient',
serialNumber: 'abcdef123456',
firmwareVersion: '3.1.2',
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.port).toEqual(80);
const validator = descriptor.getValidators()[0];
const validated = await validator.validate(result.candidate!, {});
expect(validated.matched).toBeTrue();
expect(validated.confidence).toEqual('certain');
});
tap.test('rejects unsupported AirGradient firmware versions when known', async () => {
const validator = createAirgradientDiscoveryDescriptor().getValidators()[0];
const result = await validator.validate({
source: 'mdns',
integrationDomain: 'airgradient',
host: 'airgradient-old.local',
serialNumber: 'old123',
metadata: {
discoveryProtocol: 'zeroconf',
firmwareVersion: '3.0.9',
},
}, {});
expect(result.matched).toBeFalse();
expect(result.reason).toContain('below the supported minimum');
});
export default tap.start();
@@ -0,0 +1,116 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { AirgradientClient, AirgradientMapper, type IAirgradientSnapshot } from '../../ts/integrations/airgradient/index.js';
const snapshot: IAirgradientSnapshot = {
connected: true,
updatedAt: '2026-01-01T00:00:00.000Z',
devices: [
{
host: '192.168.1.75',
name: 'Living Room AirGradient',
serialNumber: 'abcdef123456',
model: 'I-9PSL',
modelName: 'AirGradient ONE',
firmwareVersion: '3.1.2',
latestFirmwareVersion: '3.2.0',
online: true,
measures: {
signalStrength: -49,
serialNumber: 'abcdef123456',
bootTime: 4,
firmwareVersion: '3.1.2',
model: 'I-9PSL',
pm01: 2,
pm02: 6.5,
rawPm02: 7,
pm10: 9,
ambientTemperature: 21.4,
relativeHumidity: 45,
rco2: 620,
totalVolatileOrganicComponentIndex: 3,
nitrogenIndex: 1,
pm003Count: 123,
},
config: {
country: 'US',
pmStandard: 'us-aqi',
ledBarMode: 'co2',
co2AutomaticBaselineCalibrationDays: 30,
temperatureUnit: 'c',
configurationControl: 'local',
postDataToAirGradient: true,
ledBarBrightness: 80,
displayBrightness: 60,
noxLearningOffset: 60,
tvocLearningOffset: 120,
},
},
],
events: [],
};
tap.test('maps AirGradient measures and config to devices and entities', async () => {
const devices = AirgradientMapper.toDevices(snapshot);
const entities = AirgradientMapper.toEntities(snapshot);
expect(devices[0].id).toEqual('airgradient.device.abcdef123456');
expect(devices[0].manufacturer).toEqual('AirGradient');
expect(devices[0].features.some((featureArg) => featureArg.id === 'co2')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'sensor.living_room_airgradient_pm2_5')?.state).toEqual(6.5);
expect(entities.find((entityArg) => entityArg.id === 'sensor.living_room_airgradient_co2')?.state).toEqual(620);
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_pm_standard') && entityArg.platform === 'select')?.state).toEqual('us_aqi');
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_brightness') && entityArg.platform === 'number')?.state).toEqual(60);
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_update'))?.attributes?.latestVersion).toEqual('3.2.0');
});
tap.test('maps AirGradient services to safe config payloads', async () => {
const entities = AirgradientMapper.toEntities(snapshot);
const pmStandardEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_pm_standard') && entityArg.platform === 'select')!;
const brightnessEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_display_brightness') && entityArg.platform === 'number')!;
const postDataEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_post_data_to_airgradient'))!;
const ledTestEntity = entities.find((entityArg) => entityArg.uniqueId.endsWith('_led_bar_test'))!;
const pmCommand = AirgradientMapper.commandForService(snapshot, {
domain: 'select',
service: 'select_option',
target: { entityId: pmStandardEntity.id },
data: { option: 'ugm3' },
});
const brightnessCommand = AirgradientMapper.commandForService(snapshot, {
domain: 'number',
service: 'set_value',
target: { entityId: brightnessEntity.id },
data: { value: 42 },
});
const switchCommand = AirgradientMapper.commandForService(snapshot, {
domain: 'switch',
service: 'turn_off',
target: { entityId: postDataEntity.id },
});
const buttonCommand = AirgradientMapper.commandForService(snapshot, {
domain: 'button',
service: 'press',
target: { entityId: ledTestEntity.id },
});
expect(pmCommand?.type === 'set_config' ? pmCommand.payload : undefined).toEqual({ pmStandard: 'ugm3' });
expect(brightnessCommand?.type === 'set_config' ? brightnessCommand.payload : undefined).toEqual({ displayBrightness: 42 });
expect(switchCommand?.type === 'set_config' ? switchCommand.payload : undefined).toEqual({ postDataToAirGradient: false });
expect(buttonCommand?.type === 'set_config' ? buttonCommand.payload : undefined).toEqual({ ledBarTestRequested: true });
expect(AirgradientMapper.commandForService(snapshot, { domain: 'airgradient', service: 'set_config', target: {}, data: { field: 'unknown', value: true } })).toBeUndefined();
});
tap.test('does not fake live AirGradient command success for snapshot-only configs', async () => {
const client = new AirgradientClient({ snapshot });
const command = AirgradientMapper.commandForService(snapshot, {
domain: 'number',
service: 'set_value',
target: { entityId: 'number.living_room_airgradient_display_brightness' },
data: { value: 50 },
})!;
const result = await client.sendCommand(command);
expect(result.success).toBeFalse();
expect(result.error).toContain('requires config.host');
});
export default tap.start();
@@ -0,0 +1,57 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { AndroidIpWebcamClient } from '../../ts/integrations/android_ip_webcam/index.js';
tap.test('fetches live snapshots and only reports command success after Ok response', async () => {
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (inputArg: RequestInfo | URL) => {
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
requests.push(url);
if (url.endsWith('/status.json?show_avail=1')) {
return new Response(JSON.stringify({
curvals: { torch: 'off', motion_detect: 'on' },
avail: { torch: ['on', 'off'] },
audio_connections: 1,
video_connections: 0,
}), { headers: { 'content-type': 'application/json' } });
}
if (url.endsWith('/sensors.json')) {
return new Response(JSON.stringify({
battery_level: { unit: '%', data: [[ [87, 123] ]] },
motion_active: { data: [[ [1, 123] ]] },
}), { headers: { 'content-type': 'application/json' } });
}
if (url.endsWith('/enabletorch')) {
return new Response('Ok');
}
return new Response('Not Found', { status: 404 });
}) as typeof fetch;
try {
const client = new AndroidIpWebcamClient({ host: '192.168.1.20', port: 8080 });
const snapshot = await client.getSnapshot();
expect(snapshot.connected).toBeTrue();
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'battery_level')?.value).toEqual(87);
expect(snapshot.binarySensors[0].isOn).toBeTrue();
expect(snapshot.switches.find((switchArg) => switchArg.key === 'torch')?.isOn).toEqual(false);
const result = await client.execute({ type: 'torch', service: 'set_torch', activate: true });
expect(result).toEqual({ ok: true, key: 'torch', value: true });
expect(requests.some((requestArg) => requestArg.endsWith('/enabletorch'))).toBeTrue();
} finally {
globalThis.fetch = originalFetch;
}
});
tap.test('does not pretend live commands succeeded without a live endpoint', async () => {
const client = new AndroidIpWebcamClient({});
let error = '';
try {
await client.execute({ type: 'torch', service: 'set_torch', activate: true });
} catch (errorArg) {
error = errorArg instanceof Error ? errorArg.message : String(errorArg);
}
expect(error.includes('requires config.host or config.url')).toBeTrue();
});
export default tap.start();
@@ -0,0 +1,35 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { AndroidIpWebcamConfigFlow, createAndroidIpWebcamDiscoveryDescriptor } from '../../ts/integrations/android_ip_webcam/index.js';
tap.test('matches manual Android IP Webcam URL entries', async () => {
const descriptor = createAndroidIpWebcamDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const match = await matcher.matches({ url: 'http://192.168.1.20:8080', name: 'Kitchen Phone' }, {});
expect(match.matched).toBeTrue();
expect(match.candidate?.integrationDomain).toEqual('android_ip_webcam');
expect(match.candidate?.host).toEqual('192.168.1.20');
expect(match.candidate?.port).toEqual(8080);
expect(match.candidate?.metadata?.url).toEqual('http://192.168.1.20:8080');
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
expect(validation.matched).toBeTrue();
});
tap.test('matches manual Android IP Webcam host entries and configures flow', async () => {
const descriptor = createAndroidIpWebcamDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const match = await matcher.matches({ host: '192.168.1.21', name: 'Desk Phone' }, {});
expect(match.matched).toBeTrue();
expect(match.candidate?.port).toEqual(8080);
const flow = new AndroidIpWebcamConfigFlow();
const step = await flow.start(match.candidate!, {});
const done = await step.submit!({ username: 'user', password: 'pass' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.21');
expect(done.config?.port).toEqual(8080);
expect(done.config?.username).toEqual('user');
});
export default tap.start();
@@ -0,0 +1,85 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { AndroidIpWebcamMapper, type IAndroidIpWebcamSnapshot } from '../../ts/integrations/android_ip_webcam/index.js';
const snapshot: IAndroidIpWebcamSnapshot = {
deviceInfo: {
id: 'kitchen-phone',
name: 'Kitchen Phone',
manufacturer: 'Android IP Webcam',
host: '192.168.1.20',
port: 8080,
protocol: 'http',
online: true,
},
camera: {
id: 'camera',
name: 'Kitchen Phone Camera',
mjpegUrl: 'http://192.168.1.20:8080/video',
imageUrl: 'http://192.168.1.20:8080/shot.jpg',
rtspUrl: 'rtsp://192.168.1.20:8080/h264_aac.sdp',
supportedFeatures: ['stream'],
available: true,
},
sensors: [
{ key: 'audio_connections', name: 'Audio connections', value: 1, stateClass: 'total', entityCategory: 'diagnostic', available: true },
{ key: 'battery_level', name: 'Battery level', value: 87, unit: '%', deviceClass: 'battery', stateClass: 'measurement', entityCategory: 'diagnostic', available: true },
],
binarySensors: [{ key: 'motion_active', name: 'Motion active', isOn: true, deviceClass: 'motion', available: true }],
switches: [
{ key: 'torch', name: 'Torch', isOn: false, command: 'torch', entityCategory: 'config', available: true },
{ key: 'motion_detect', name: 'Motion detection', isOn: true, command: 'setting', entityCategory: 'config', available: true },
],
statusData: { audio_connections: 1, curvals: { torch: 'off', motion_detect: 'on' } },
sensorData: { battery_level: { unit: '%', data: [[ [87, 123] ]] }, motion_active: { data: [[ [1, 123] ]] } },
currentSettings: { torch: false, motion_detect: true },
enabledSensors: ['battery_level', 'motion_active'],
enabledSettings: ['torch', 'motion_detect'],
availableSettings: {},
connected: true,
updatedAt: '2026-01-01T00:00:00.000Z',
};
tap.test('maps Android IP Webcam camera, sensors, binary sensor, and switches', async () => {
const devices = AndroidIpWebcamMapper.toDevices(snapshot);
const entities = AndroidIpWebcamMapper.toEntities(snapshot);
expect(devices.length).toEqual(1);
expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'camera.kitchen_phone_camera')?.attributes?.mjpegUrl).toEqual('http://192.168.1.20:8080/video');
expect(entities.find((entityArg) => entityArg.id === 'sensor.battery_level')?.state).toEqual(87);
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.motion_active')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'switch.torch')?.state).toEqual('off');
});
tap.test('models camera and setting services as explicit commands', async () => {
const streamCommand = AndroidIpWebcamMapper.commandForService(snapshot, {
domain: 'camera',
service: 'stream_source',
target: { entityId: 'camera.kitchen_phone_camera' },
});
const snapshotCommand = AndroidIpWebcamMapper.commandForService(snapshot, {
domain: 'camera',
service: 'snapshot',
target: { entityId: 'camera.kitchen_phone_camera' },
});
const torchCommand = AndroidIpWebcamMapper.commandForService(snapshot, {
domain: 'switch',
service: 'turn_on',
target: { entityId: 'switch.torch' },
});
const zoomCommand = AndroidIpWebcamMapper.commandForService(snapshot, {
domain: 'android_ip_webcam',
service: 'set_zoom',
target: {},
data: { zoom: '42' },
});
expect(streamCommand?.type).toEqual('stream_source');
expect(snapshotCommand?.type).toEqual('snapshot_image');
expect(torchCommand?.type).toEqual('torch');
expect(torchCommand?.activate).toBeTrue();
expect(zoomCommand?.type).toEqual('set_zoom');
expect(zoomCommand?.zoom).toEqual(42);
});
export default tap.start();
+45
View File
@@ -0,0 +1,45 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../../ts/plugins.js';
import { ApcupsdClient } from '../../ts/integrations/apcupsd/index.js';
const statusText = `UPSNAME : TCP UPS
STATUS : ONBATT LOWBATT
BCHARGE : 17.0 Percent
STATFLAG : 0x05000000
`;
tap.test('requests APCUPSd NIS status over local TCP', async () => {
const server = plugins.net.createServer((socketArg) => {
socketArg.once('data', (chunkArg) => {
const buffer = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg);
const length = buffer.readUInt16BE(0);
const command = buffer.subarray(2, 2 + length).toString('utf8');
expect(command).toEqual('status');
socketArg.write(frame(statusText));
socketArg.write(Buffer.alloc(2));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const snapshot = await new ApcupsdClient({ host: '127.0.0.1', port, timeoutMs: 1000 }).getSnapshot();
expect(snapshot.online).toBeTrue();
expect(snapshot.ups.name).toEqual('TCP UPS');
expect(snapshot.ups.lineOnline).toBeFalse();
expect(snapshot.battery.chargePercent).toEqual(17);
} finally {
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
}
});
const frame = (valueArg: string): Buffer => {
const payload = Buffer.from(valueArg, 'utf8');
const result = Buffer.alloc(payload.length + 2);
result.writeUInt16BE(payload.length, 0);
payload.copy(result, 2);
return result;
};
export default tap.start();
@@ -0,0 +1,25 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createApcupsdDiscoveryDescriptor } from '../../ts/integrations/apcupsd/index.js';
tap.test('matches and validates manual APCUPSd entries', async () => {
const descriptor = createApcupsdDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ host: '192.168.1.60', name: 'Rack APC UPS' }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('apcupsd');
expect(result.candidate?.port).toEqual(3551);
const validator = descriptor.getValidators()[0];
const validation = await validator.validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
expect(validation.normalizedDeviceId).toEqual('192.168.1.60:3551');
});
tap.test('rejects manual entries without APCUPSd hints', async () => {
const descriptor = createApcupsdDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ name: 'Generic device' }, {});
expect(result.matched).toBeFalse();
});
export default tap.start();
+61
View File
@@ -0,0 +1,61 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ApcupsdClient, ApcupsdMapper, type IApcupsdSnapshot } from '../../ts/integrations/apcupsd/index.js';
const rawStatus = `APC : 001,043,1036
DATE : 2026-01-01 00:00:00 +0000
HOSTNAME : nas
VERSION : 3.14.14
UPSNAME : Office UPS
MODEL : Back-UPS ES 700
STATUS : ONLINE
LINEV : 230.0 Volts
LOADPCT : 21.0 Percent
BCHARGE : 98.0 Percent
TIMELEFT : 42.5 Minutes
BATTV : 13.6 Volts
LINEFREQ : 50.0 Hz
OUTPUTV : 230.1 Volts
NOMPOWER : 405 Watts
SERIALNO : AS1234567890
STATFLAG : 0x05000008 Status Flag
END APC : 2026-01-01 00:00:00 +0000
`;
tap.test('parses APCUPSd status output into a safe snapshot', async () => {
const snapshot = await new ApcupsdClient({ rawStatus }).getSnapshot();
expect(snapshot.ups.name).toEqual('Office UPS');
expect(snapshot.ups.serialNumber).toEqual('AS1234567890');
expect(snapshot.ups.lineOnline).toBeTrue();
expect(snapshot.battery.chargePercent).toEqual(98);
expect(snapshot.battery.timeLeftMinutes).toEqual(42.5);
expect(snapshot.power.lineVoltage).toEqual(230);
expect(snapshot.power.loadPercent).toEqual(21);
});
tap.test('maps APCUPSd snapshot to canonical devices and entities', async () => {
const snapshot = await new ApcupsdClient({ rawStatus }).getSnapshot();
const devices = ApcupsdMapper.toDevices(snapshot);
const entities = ApcupsdMapper.toEntities(snapshot);
expect(devices.some((deviceArg) => deviceArg.id === 'apcupsd.ups.as1234567890')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.office_ups_online_status')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_battery_charge')?.state).toEqual(98);
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_input_voltage')?.attributes?.unit).toEqual('V');
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_ups_nominal_output_power')?.state).toEqual(405);
});
tap.test('maps offline snapshots without inventing unavailable sensor values', async () => {
const snapshot: IApcupsdSnapshot = {
ups: { id: 'offline-ups', name: 'Offline UPS' },
battery: {},
power: {},
status: {},
online: false,
updatedAt: '2026-01-01T00:00:00.000Z',
};
const entities = ApcupsdMapper.toEntities(snapshot);
expect(entities.find((entityArg) => entityArg.id === 'sensor.offline_ups_status')?.available).toBeFalse();
expect(entities.some((entityArg) => entityArg.id === 'sensor.offline_ups_battery_charge')).toBeFalse();
});
export default tap.start();
+37
View File
@@ -0,0 +1,37 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createBleboxDiscoveryDescriptor } from '../../ts/integrations/blebox/index.js';
tap.test('matches BleBox zeroconf records and validates manual candidates', async () => {
const descriptor = createBleboxDiscoveryDescriptor();
const mdnsMatcher = descriptor.getMatchers()[0];
const mdnsResult = await mdnsMatcher.matches({
name: 'blebox-1afe34e750b8',
type: '_bbxsrv._tcp.local.',
host: 'blebox.local',
port: 80,
txt: {
id: '1afe34e750b8',
type: 'switchBoxD',
deviceName: 'Kitchen Switch',
},
}, {});
expect(mdnsResult.matched).toBeTrue();
expect(mdnsResult.normalizedDeviceId).toEqual('1afe34e750b8');
expect(mdnsResult.candidate?.manufacturer).toEqual('BleBox');
const manualMatcher = descriptor.getMatchers()[1];
const manualResult = await manualMatcher.matches({ host: '192.168.1.50' }, {});
expect(manualResult.matched).toBeTrue();
expect(manualResult.candidate?.port).toEqual(80);
const validator = descriptor.getValidators()[0];
const validResult = await validator.validate({
source: 'manual',
integrationDomain: 'blebox',
host: '192.168.1.50',
port: 80,
}, {});
expect(validResult.matched).toBeTrue();
});
export default tap.start();
+94
View File
@@ -0,0 +1,94 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BleboxMapper } from '../../ts/integrations/blebox/index.js';
import type { IBleboxSnapshot } from '../../ts/integrations/blebox/index.js';
tap.test('maps BleBox switch, power sensor, light, cover, and moisture snapshots', async () => {
const switchSnapshot: IBleboxSnapshot = {
device: {
id: '1afe34e750b8',
type: 'switchBoxD',
deviceName: 'Kitchen Switch',
fv: '0.200',
hv: '0.7',
apiLevel: 20200831,
},
state: {
relays: [
{ relay: 0, state: 1, name: 'Counter' },
{ relay: 1, state: 0, name: 'Sink' },
],
sensors: [{ type: 'activePower', id: 0, value: 12.5 }],
},
};
const switchEntities = BleboxMapper.toEntities(switchSnapshot);
expect(switchEntities.find((entityArg) => entityArg.id === 'switch.kitchen_switch_relay_0')?.state).toEqual('on');
expect(switchEntities.find((entityArg) => entityArg.id === 'sensor.kitchen_switch_activepower_0')?.state).toEqual(12.5);
const lightSnapshot: IBleboxSnapshot = {
device: {
id: '2bee34e750b8',
type: 'wLightBox',
deviceName: 'Cabinet Light',
fv: '0.993',
hv: '4.3',
apiLevel: 20200229,
},
extendedState: {
rgbw: {
desiredColor: 'fa00203a',
colorMode: 4,
effectID: 0,
effectsNames: { 0: 'NONE', 1: 'FADE' },
},
},
};
const lightEntity = BleboxMapper.toEntities(lightSnapshot)[0];
expect(lightEntity.id).toEqual('light.cabinet_light_color');
expect(lightEntity.attributes?.brightness).toEqual(250);
expect(lightEntity.attributes?.colorMode).toEqual('rgbw');
const coverSnapshot: IBleboxSnapshot = {
device: {
id: '3cee34e750b8',
type: 'shutterBox',
deviceName: 'Bedroom Shutter',
fv: '0.147',
hv: '0.7',
apiLevel: 20180604,
},
state: {
shutter: {
state: 1,
desiredPos: { position: 25, tilt: 80 },
},
},
};
const coverEntity = BleboxMapper.toEntities(coverSnapshot)[0];
expect(coverEntity.state).toEqual('opening');
expect(coverEntity.attributes?.currentPosition).toEqual(75);
expect(coverEntity.attributes?.currentTiltPosition).toEqual(20);
const sensorSnapshot: IBleboxSnapshot = {
device: {
id: '4dee34e750b8',
type: 'multiSensor',
deviceName: 'Garden Sensor',
fv: '1.0',
hv: '1.0',
apiLevel: 20230606,
},
extendedState: {
multiSensor: {
sensors: [
{ id: 0, type: 'temperature', value: 2234 },
{ id: 1, type: 'flood', value: 1 },
],
},
},
};
const sensorEntities = BleboxMapper.toEntities(sensorSnapshot);
expect(sensorEntities.find((entityArg) => entityArg.id === 'sensor.garden_sensor_temperature_0')?.state).toEqual(22.34);
expect(sensorEntities.find((entityArg) => entityArg.id === 'binary_sensor.garden_sensor_flood_1')?.state).toEqual('on');
});
export default tap.start();
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BleboxIntegration } from '../../ts/integrations/blebox/index.js';
import type { IBleboxSnapshot } from '../../ts/integrations/blebox/index.js';
const switchSnapshot: IBleboxSnapshot = {
device: {
id: '1afe34e750b8',
type: 'switchBoxD',
deviceName: 'Kitchen Switch',
fv: '0.200',
hv: '0.7',
apiLevel: 20200831,
},
state: {
relays: [
{ relay: 0, state: 0, name: 'Counter' },
{ relay: 1, state: 0, name: 'Sink' },
],
},
};
tap.test('runs safe BleBox switch commands through modeled local HTTP paths', async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
calls.push({ url: String(urlArg), method: initArg?.method });
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
}) as typeof globalThis.fetch;
try {
const runtime = await new BleboxIntegration().setup({ host: '192.168.1.50', snapshot: switchSnapshot }, {});
const result = await runtime.callService?.({
domain: 'switch',
service: 'turn_on',
target: { entityId: 'switch.kitchen_switch_relay_1' },
});
expect(result?.success).toBeTrue();
expect(calls[0].url).toEqual('http://192.168.1.50/s/1/1');
await runtime.destroy();
} finally {
globalThis.fetch = originalFetch;
}
});
tap.test('rejects unsafe BleBox light service payloads before HTTP commands', async () => {
const runtime = await new BleboxIntegration().setup({ host: '192.168.1.50', snapshot: {
device: {
id: '2bee34e750b8',
type: 'wLightBox',
deviceName: 'Cabinet Light',
fv: '0.993',
hv: '4.3',
apiLevel: 20200229,
},
extendedState: { rgbw: { desiredColor: 'fa00203a', colorMode: 4, effectID: 0 } },
} }, {});
const result = await runtime.callService?.({
domain: 'light',
service: 'turn_on',
target: { entityId: 'light.cabinet_light_color' },
data: { brightness: 999 },
});
expect(result?.success).toBeFalse();
expect(result?.error).toContain('brightness');
await runtime.destroy();
});
tap.test('config flow returns a local HTTP config and validates credentials', async () => {
const integration = new BleboxIntegration();
const step = await integration.configFlow.start({ source: 'manual', integrationDomain: 'blebox', host: '192.168.1.50' }, {});
const incomplete = await step.submit?.({ host: '192.168.1.50', port: 80, username: 'admin' });
expect(incomplete?.kind).toEqual('error');
const done = await step.submit?.({ host: '192.168.1.50', port: 80, username: 'admin', password: 'secret' });
expect(done?.kind).toEqual('done');
expect(done?.config?.host).toEqual('192.168.1.50');
expect(done?.config?.protocol).toEqual('http');
});
export default tap.start();
@@ -0,0 +1,56 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BroadlinkConfigFlow, createBroadlinkDiscoveryDescriptor } from '../../ts/integrations/broadlink/index.js';
tap.test('matches Broadlink DHCP candidates by Home Assistant MAC prefix', async () => {
const descriptor = createBroadlinkDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'broadlink-dhcp-match');
const result = await matcher?.matches({
ipAddress: '192.168.1.50',
macAddress: '34:EA:34:B4:5D:2C',
hostname: 'rm4-bedroom',
}, {});
expect(result?.matched).toBeTrue();
expect(result?.candidate?.integrationDomain).toEqual('broadlink');
expect(result?.candidate?.host).toEqual('192.168.1.50');
expect(result?.candidate?.metadata?.discoveryProtocol).toEqual('dhcp');
});
tap.test('matches manual Broadlink snapshot and learned-code entries', async () => {
const descriptor = createBroadlinkDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'broadlink-manual-match');
const result = await matcher?.matches({
host: '192.168.1.51',
type: 'RM4PRO',
macAddress: '34ea34b45d2d',
codes: { television: { power: 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=' } },
switches: [{ name: 'Television', commandOn: 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=' }],
}, {});
expect(result?.matched).toBeTrue();
expect(result?.candidate?.metadata?.deviceType).toEqual('RM4PRO');
expect(result?.candidate?.metadata?.codesConfigured).toBeTrue();
expect(result?.candidate?.metadata?.customSwitchesConfigured).toBeTrue();
});
tap.test('builds Broadlink config from candidate and rejects invalid snapshots', async () => {
const flow = new BroadlinkConfigFlow();
const step = await flow.start({
source: 'manual',
integrationDomain: 'broadlink',
id: '34:ea:34:b4:5d:2c',
host: '192.168.1.52',
macAddress: '34:ea:34:b4:5d:2c',
model: 'RM4PRO',
metadata: { deviceType: 'RM4PRO', devtype: 0x520b },
}, {});
const done = await step.submit?.({ host: '192.168.1.52', timeout: 7, name: 'Bedroom RM4', type: 'RM4PRO' });
expect(done?.kind).toEqual('done');
expect(done?.config?.host).toEqual('192.168.1.52');
expect(done?.config?.timeout).toEqual(7);
expect(done?.config?.type).toEqual('RM4PRO');
const invalid = await step.submit?.({ host: '192.168.1.52', snapshotJson: '{"connected":true}' });
expect(invalid?.kind).toEqual('error');
});
export default tap.start();
@@ -0,0 +1,138 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BroadlinkClient, BroadlinkIntegration, BroadlinkMapper } from '../../ts/integrations/broadlink/index.js';
import type { IBroadlinkCommand, IBroadlinkSnapshot } from '../../ts/integrations/broadlink/index.js';
const irCode = 'JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=';
const snapshot: IBroadlinkSnapshot = {
connected: true,
host: '192.168.1.50',
port: 80,
events: [],
entities: [],
devices: [
{
id: '34ea34b45d2c',
host: '192.168.1.50',
macAddress: '34:ea:34:b4:5d:2c',
type: 'RM4PRO',
name: 'Bedroom RM4 Pro',
model: 'RM4 pro',
available: true,
state: { temperature: 23.5, humidity: 45 },
codes: { television: { power: irCode } },
switches: [{ name: 'Bedroom TV', commandOn: irCode, commandOff: irCode }],
},
{
id: 'desk-plug',
host: '192.168.1.60',
macAddress: '24:df:a7:00:00:01',
type: 'SP4B',
name: 'Desk Plug',
available: true,
state: { pwr: 1, power: 12.3, volt: 230.1, current: 0.11, totalconsum: 1.5 },
},
],
};
tap.test('maps Broadlink snapshots to remote, switch, and sensor entities', async () => {
const entities = BroadlinkMapper.toEntities(snapshot);
expect(entities.find((entityArg) => entityArg.id === 'remote.bedroom_rm4_pro')?.platform).toEqual('remote');
expect(entities.find((entityArg) => entityArg.id === 'remote.bedroom_rm4_pro')?.attributes?.supportsRf).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'switch.bedroom_tv')?.state).toEqual('off');
expect(entities.find((entityArg) => entityArg.id === 'switch.desk_plug')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'sensor.bedroom_rm4_pro_temperature')?.state).toEqual(23.5);
expect(entities.find((entityArg) => entityArg.id === 'sensor.desk_plug_power')?.attributes?.unitOfMeasurement).toEqual('W');
const devices = BroadlinkMapper.toDevices(snapshot);
expect(devices.find((deviceArg) => deviceArg.id === 'broadlink.device.34ea34b45d2c')?.features.some((featureArg) => featureArg.id === 'remote')).toBeTrue();
expect(devices.find((deviceArg) => deviceArg.id === 'broadlink.device.desk_plug')?.features.some((featureArg) => featureArg.id === 'power')).toBeTrue();
});
tap.test('maps learned remote commands, raw IR, RF, and switch services', async () => {
const remoteCommand = BroadlinkMapper.commandForService(snapshot, {
domain: 'remote',
service: 'send_command',
target: { entityId: 'remote.bedroom_rm4_pro' },
data: { device: 'television', command: 'power', num_repeats: 2 },
});
expect(remoteCommand?.method).toEqual('send_data');
expect(remoteCommand?.packets?.[0].kind).toEqual('ir');
expect(remoteCommand?.packets?.[0].firstByte).toEqual(0x26);
expect(remoteCommand?.numRepeats).toEqual(2);
const irCommand = BroadlinkMapper.commandForService(snapshot, {
domain: 'infrared',
service: 'send_command',
target: { entityId: 'remote.bedroom_rm4_pro' },
data: { rawTimings: [9000, -4500, 560, -560] },
});
expect(irCommand?.packet?.kind).toEqual('ir');
expect(irCommand?.packet?.firstByte).toEqual(0x26);
const rfCommand = BroadlinkMapper.commandForService(snapshot, {
domain: 'radio_frequency',
service: 'send_command',
target: { entityId: 'remote.bedroom_rm4_pro' },
data: { frequency: 433_920_000, rawTimings: [300, -900, 300, -900], repeat_count: 3 },
});
expect(rfCommand?.packet?.kind).toEqual('rf433');
expect(rfCommand?.packet?.firstByte).toEqual(0xb2);
expect(rfCommand?.packet?.repeatCount).toEqual(3);
const switchCommand = BroadlinkMapper.commandForService(snapshot, {
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.desk_plug' },
data: {},
});
expect(switchCommand?.method).toEqual('set_state');
expect(switchCommand?.payload?.pwr).toEqual(false);
});
tap.test('does not report live UDP success without an injected executor', async () => {
const integration = new BroadlinkIntegration();
const runtime = await integration.setup({ snapshot }, {});
const result = await runtime.callService?.({
domain: 'remote',
service: 'send_command',
target: { entityId: 'remote.bedroom_rm4_pro' },
data: { device: 'television', command: 'power' },
});
expect(result?.success).toBeFalse();
expect(String(result?.error).includes('not implemented')).toBeTrue();
});
tap.test('delegates mapped commands to injected Broadlink executor', async () => {
const commands: IBroadlinkCommand[] = [];
const integration = new BroadlinkIntegration();
const runtime = await integration.setup({
snapshot,
commandExecutor: async (commandArg) => {
commands.push(commandArg);
return { success: true, transmitted: true, data: { ok: true } };
},
}, {});
const result = await runtime.callService?.({
domain: 'remote',
service: 'send_command',
target: { entityId: 'remote.bedroom_rm4_pro' },
data: { device: 'television', command: 'power' },
});
expect(result?.success).toBeTrue();
expect(commands[0].method).toEqual('send_data');
expect(commands[0].packets?.[0].base64).toEqual(BroadlinkClient.packetFromBase64(irCode).base64);
});
tap.test('exposes native Broadlink IR and RF packet encoders', async () => {
const irPacket = BroadlinkClient.irPacketFromTimings([9000, -4500, 560, -560]);
expect(irPacket.firstByte).toEqual(0x26);
expect(irPacket.kind).toEqual('ir');
const rfPacket = BroadlinkClient.rfPacketFromTimings({ frequency: 315_000_000, timings: [300, -900], repeatCount: 1 });
expect(rfPacket.firstByte).toEqual(0xb4);
expect(rfPacket.kind).toEqual('rf315');
});
export default tap.start();
+39
View File
@@ -0,0 +1,39 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DsmrConfigFlow, createDsmrDiscoveryDescriptor } from '../../ts/integrations/dsmr/index.js';
tap.test('matches manual DSMR network entries', async () => {
const descriptor = createDsmrDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ host: 'p1-reader.local', port: 2001, name: 'DSMR P1 bridge', metadata: { dsmr: true, dsmrVersion: '5' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('dsmr');
expect(result.candidate?.metadata?.connectionType).toEqual('network');
expect(result.candidate?.metadata?.liveValidation).toBeFalse();
});
tap.test('matches manual DSMR serial entries and validates candidates', async () => {
const descriptor = createDsmrDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ serialPort: '/dev/ttyUSB0', name: 'DSMR P1 cable', metadata: { p1: true } }, {});
const validator = descriptor.getValidators()[0];
const validation = await validator.validate(result.candidate!, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.metadata?.connectionType).toEqual('serial');
expect(validation.matched).toBeTrue();
expect(validation.reason).toContain('live communication is not assumed');
});
tap.test('config flow creates network config without claiming connection success', async () => {
const flow = new DsmrConfigFlow();
const step = await flow.start({ source: 'manual', id: 'p1-reader', host: 'p1-reader.local', port: 2001, metadata: { connectionType: 'network', dsmrVersion: '5' } }, {});
const result = await step.submit!({ connectionType: 'network', host: 'p1-reader.local', port: 2001, dsmrVersion: '5', protocol: 'dsmr_protocol', liveRead: false });
expect(result.kind).toEqual('done');
expect(result.config?.connectionType).toEqual('network');
expect(result.config?.connected).toBeFalse();
expect(result.config?.liveRead).toBeFalse();
});
export default tap.start();
+73
View File
@@ -0,0 +1,73 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DsmrClient, DsmrMapper, DsmrTelegramParser, type IDsmrConfig } from '../../ts/integrations/dsmr/index.js';
const sampleTelegram = `/ISk5\\2MT382-1000
1-3:0.2.8(50)
0-0:1.0.0(240101120000W)
0-0:96.1.1(453030333630303337383931323334)
1-0:1.8.1(00123.456*kWh)
1-0:1.8.2(00234.567*kWh)
1-0:2.8.1(00012.345*kWh)
1-0:2.8.2(00023.456*kWh)
0-0:96.14.0(0002)
1-0:1.7.0(01.193*kW)
1-0:2.7.0(00.000*kW)
1-0:21.7.0(00.378*kW)
1-0:41.7.0(00.400*kW)
1-0:61.7.0(00.415*kW)
0-1:24.1.0(003)
0-1:96.1.0(473030333930303137)
0-1:24.2.1(240101110000W)(00024.123*m3)
!ABCD`;
tap.test('parses DSMR telegrams and maps energy gas and power sensors', async () => {
const snapshot = DsmrTelegramParser.parseTelegram(sampleTelegram, { config: { dsmrVersion: '5', connectionType: 'serial', serialPort: '/dev/ttyUSB0' } });
const entities = DsmrMapper.toEntities(snapshot);
const devices = DsmrMapper.toDevices(snapshot);
expect(snapshot.connected).toBeTrue();
expect(snapshot.telegram?.objects.some((objectArg) => objectArg.obisCode === '1-0:1.7.0')).toBeTrue();
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'current_electricity_usage')?.value).toEqual(1.193);
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'electricity_used_tariff_1')?.value).toEqual(123.456);
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'electricity_active_tariff')?.value).toEqual('normal');
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'hourly_gas_meter_reading')?.value).toEqual(24.123);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'current_electricity_usage')?.attributes?.unitOfMeasurement).toEqual('kW');
expect(entities.find((entityArg) => entityArg.attributes?.key === 'hourly_gas_meter_reading')?.deviceId).toContain('dsmr.gas');
expect(devices.some((deviceArg) => deviceArg.id.startsWith('dsmr.electricity.'))).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id.startsWith('dsmr.gas.'))).toBeTrue();
});
tap.test('maps status snapshots without raw telegrams', async () => {
const config: IDsmrConfig = {
id: 'meter-status',
dsmrVersion: '5',
status: {
meter: { serialId: 'E123', serialIdGas: 'G123' },
values: {
current_electricity_usage: { value: 0.456, unit: 'kW' },
electricity_used_tariff_1: { value: 12.3, unit: 'kWh' },
hourly_gas_meter_reading: { value: 4.2, unit: 'm3' },
},
},
};
const snapshot = await new DsmrClient(config).getSnapshot();
const entities = DsmrMapper.toEntities(snapshot);
expect(snapshot.source).toEqual('status');
expect(entities.find((entityArg) => entityArg.attributes?.key === 'current_electricity_usage')?.state).toEqual(0.456);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'electricity_used_tariff_1')?.attributes?.stateClass).toEqual('total_increasing');
expect(entities.find((entityArg) => entityArg.attributes?.key === 'hourly_gas_meter_reading')?.state).toEqual(4.2);
});
tap.test('does not fake live serial refresh success without a telegram source', async () => {
const client = new DsmrClient({ connectionType: 'serial', serialPort: '/dev/ttyUSB0', port: '/dev/ttyUSB0', dsmrVersion: '5' });
const snapshot = await client.getSnapshot();
const result = await client.refresh();
expect(snapshot.connected).toBeFalse();
expect(snapshot.sensors.length).toEqual(0);
expect(result.success).toBeFalse();
expect(result.error).toContain('telegramProvider');
});
export default tap.start();