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();
|
||||
Reference in New Issue
Block a user