Add native local infrastructure integrations

This commit is contained in:
2026-05-05 19:06:21 +00:00
parent cfab8c593e
commit a144ef687c
70 changed files with 11607 additions and 183 deletions
@@ -0,0 +1,54 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BoschShcConfigFlow, createBoschShcDiscoveryDescriptor } from '../../ts/integrations/bosch_shc/index.js';
import type { IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js';
tap.test('matches Bosch SHC zeroconf records and validates manual candidates', async () => {
const descriptor = createBoschShcDiscoveryDescriptor();
const mdnsMatcher = descriptor.getMatchers()[0];
const mdnsResult = await mdnsMatcher.matches({
name: 'Bosch SHC [AA:BB:CC:DD:EE:FF]._http._tcp.local.',
type: '_http._tcp.local.',
host: 'bosch-shc.local',
port: 80,
}, {});
expect(mdnsResult.matched).toBeTrue();
expect(mdnsResult.normalizedDeviceId).toEqual('aa-bb-cc-dd-ee-ff');
expect(mdnsResult.candidate?.integrationDomain).toEqual('bosch_shc');
expect(mdnsResult.candidate?.manufacturer).toEqual('Bosch');
const manualMatcher = descriptor.getMatchers()[1];
const manualResult = await manualMatcher.matches({ host: '192.168.1.10' }, {});
expect(manualResult.matched).toBeTrue();
expect(manualResult.candidate?.host).toEqual('192.168.1.10');
const validator = descriptor.getValidators()[0];
const validResult = await validator.validate({
source: 'manual',
integrationDomain: 'bosch_shc',
host: '192.168.1.10',
id: 'AA:BB:CC:DD:EE:FF',
}, {});
expect(validResult.matched).toBeTrue();
});
tap.test('config flow preserves credentials and refuses native pairing without executor', async () => {
const flow = new BoschShcConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'bosch_shc', host: '192.168.1.10' }, {});
const pairing = await step.submit?.({ host: '192.168.1.10', password: 'secret' });
expect(pairing?.kind).toEqual('error');
expect(pairing?.error).toContain('executor');
const done = await step.submit?.({ host: '192.168.1.10', sslCertificate: '/config/bosch/cert.pem', sslKey: '/config/bosch/key.pem', token: 'token:bosch-shc' });
expect(done?.kind).toEqual('done');
expect(done?.config?.host).toEqual('192.168.1.10');
expect(done?.config?.sslCertificate).toEqual('/config/bosch/cert.pem');
const snapshot: IBoschShcSnapshot = { host: '192.168.1.10', devices: [], information: { macAddress: 'AA:BB:CC:DD:EE:FF' } };
const snapshotDone = await step.submit?.({ host: '192.168.1.10', snapshotJson: JSON.stringify(snapshot) });
expect(snapshotDone?.kind).toEqual('done');
expect(snapshotDone?.config?.snapshot?.information?.macAddress).toEqual('AA:BB:CC:DD:EE:FF');
});
export default tap.start();
@@ -0,0 +1,68 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BoschShcMapper } from '../../ts/integrations/bosch_shc/index.js';
import type { IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js';
const snapshot: IBoschShcSnapshot = {
host: '192.168.1.10',
name: 'Home Controller',
information: {
shcIpAddress: '192.168.1.10',
macAddress: 'AA:BB:CC:DD:EE:FF',
softwareUpdateState: {
swInstalledVersion: '10.20.3000',
swUpdateState: 'NO_UPDATE_AVAILABLE',
},
},
devices: [
{ id: 'hdm:HomeMaticIP:trv', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'TRV', manufacturer: 'BOSCH', serial: 'TRV123', name: 'Radiator', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['TemperatureLevel', 'ValveTappet', 'BatteryLevel'] },
{ id: 'hdm:HomeMaticIP:window', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'SWD', manufacturer: 'BOSCH', serial: 'SWD123', name: 'Patio Window', status: 'AVAILABLE', profile: 'REGULAR_WINDOW', deviceServiceIds: ['ShutterContact', 'BatteryLevel'] },
{ id: 'hdm:ZigBee:plug', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'PSM', manufacturer: 'BOSCH', serial: 'PSM123', name: 'Kitchen Plug', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PowerSwitch', 'PowerMeter', 'Routing'] },
{ id: 'camera:360', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'CAMERA_360', manufacturer: 'BOSCH', serial: 'CAM360', name: 'Porch Camera', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PrivacyMode'] },
{ id: 'light:hue', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'HUE_LIGHT', manufacturer: 'BOSCH', serial: 'HUE123', name: 'Desk Lamp', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['BinarySwitch', 'MultiLevelSwitch', 'HueColorTemperature'] },
{ id: 'shutter:living', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'BBL', manufacturer: 'BOSCH', serial: 'BBL123', name: 'Living Shutter', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['ShutterControl'] },
{ id: 'roomClimateControl_hz_1', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'ROOM_CLIMATE_CONTROL', manufacturer: 'BOSCH', serial: 'RCC123', name: 'Living Climate', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['TemperatureLevel', 'RoomClimateControl'] },
],
services: [
{ id: 'TemperatureLevel', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'temperatureLevelState', temperature: 21.3 } },
{ id: 'ValveTappet', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'valveTappetState', position: 44, value: 'VALVE_ADAPTION_SUCCESSFUL' } },
{ id: 'BatteryLevel', deviceId: 'hdm:HomeMaticIP:trv', state: { '@type': 'batteryLevelState' }, faults: { entries: [{ type: 'OK' }] } },
{ id: 'ShutterContact', deviceId: 'hdm:HomeMaticIP:window', state: { '@type': 'shutterContactState', value: 'OPEN' } },
{ id: 'BatteryLevel', deviceId: 'hdm:HomeMaticIP:window', state: { '@type': 'batteryLevelState' }, faults: { entries: [{ type: 'LOW_BATTERY' }] } },
{ id: 'PowerSwitch', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerSwitchState', switchState: 'ON', automaticPowerOffTime: 0 } },
{ id: 'PowerMeter', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerMeterState', powerConsumption: 123, energyConsumption: 4567 } },
{ id: 'Routing', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'routingState', value: 'DISABLED' } },
{ id: 'PrivacyMode', deviceId: 'camera:360', state: { '@type': 'privacyModeState', value: 'DISABLED' } },
{ id: 'BinarySwitch', deviceId: 'light:hue', state: { '@type': 'binarySwitchState', on: true } },
{ id: 'MultiLevelSwitch', deviceId: 'light:hue', state: { '@type': 'multiLevelSwitchState', level: 42 } },
{ id: 'HueColorTemperature', deviceId: 'light:hue', state: { '@type': 'hueColorTemperatureState', colorTemperature: 300, colorTemperatureRange: { minCt: 153, maxCt: 500 } } },
{ id: 'ShutterControl', deviceId: 'shutter:living', state: { '@type': 'shutterControlState', operationState: 'CLOSING', level: 0.25, calibrated: true } },
{ id: 'TemperatureLevel', deviceId: 'roomClimateControl_hz_1', state: { '@type': 'temperatureLevelState', temperature: 20.5 } },
{ id: 'RoomClimateControl', deviceId: 'roomClimateControl_hz_1', state: { '@type': 'roomClimateControlState', operationMode: 'MANUAL', setpointTemperature: 22, boostMode: false, low: false, summerMode: false, supportsBoostMode: true } },
],
};
tap.test('maps Bosch SHC snapshot devices and entities', async () => {
const devices = BoschShcMapper.toDevices(snapshot);
const entities = BoschShcMapper.toEntities(snapshot);
expect(devices.some((deviceArg) => deviceArg.id === 'bosch_shc.controller.aa_bb_cc_dd_ee_ff')).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'cover'))).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'light'))).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'climate'))).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'sensor.radiator_temperature')?.state).toEqual(21.3);
expect(entities.find((entityArg) => entityArg.id === 'sensor.radiator_valvetappet')?.attributes?.valve_tappet_state).toEqual('VALVE_ADAPTION_SUCCESSFUL');
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.patio_window')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.patio_window_battery')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_plug')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_plug_routing')?.state).toEqual('off');
expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_plug_power')?.state).toEqual(123);
expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_plug_energy')?.state).toEqual(4.567);
expect(entities.find((entityArg) => entityArg.id === 'switch.porch_camera_privacy_mode')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'light.desk_lamp')?.attributes?.brightness).toEqual(42);
expect(entities.find((entityArg) => entityArg.id === 'cover.living_shutter')?.attributes?.currentPosition).toEqual(25);
expect(entities.find((entityArg) => entityArg.id === 'cover.living_shutter')?.state).toEqual('closing');
expect(entities.find((entityArg) => entityArg.id === 'climate.living_climate')?.attributes?.targetTemperature).toEqual(22);
});
export default tap.start();
@@ -0,0 +1,93 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BoschShcIntegration } from '../../ts/integrations/bosch_shc/index.js';
import type { IBoschShcModeledCommand, IBoschShcSnapshot } from '../../ts/integrations/bosch_shc/index.js';
const snapshot: IBoschShcSnapshot = {
host: '192.168.1.10',
devices: [
{ id: 'hdm:ZigBee:plug', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'PSM', manufacturer: 'BOSCH', serial: 'PSM123', name: 'Kitchen Plug', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['PowerSwitch'] },
{ id: 'shutter:living', rootDeviceId: 'AA:BB:CC:DD:EE:FF', deviceModel: 'BBL', manufacturer: 'BOSCH', serial: 'BBL123', name: 'Living Shutter', status: 'AVAILABLE', profile: 'GENERIC', deviceServiceIds: ['ShutterControl'] },
],
services: [
{ id: 'PowerSwitch', deviceId: 'hdm:ZigBee:plug', state: { '@type': 'powerSwitchState', switchState: 'ON', automaticPowerOffTime: 0 } },
{ id: 'ShutterControl', deviceId: 'shutter:living', state: { '@type': 'shutterControlState', operationState: 'STOPPED', level: 0.5, calibrated: true } },
],
};
tap.test('runs modeled Bosch SHC service commands through an injected executor', async () => {
const calls: IBoschShcModeledCommand[] = [];
const runtime = await new BoschShcIntegration().setup({
host: '192.168.1.10',
snapshot,
executor: async (commandArg) => {
calls.push(commandArg);
return { ok: true };
},
}, {});
const result = await runtime.callService?.({
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.kitchen_plug' },
});
expect(result?.success).toBeTrue();
expect(calls[0].action).toEqual('put_device_service_state');
expect(calls[0].path).toEqual('/devices/hdm:ZigBee:plug/services/PowerSwitch/state');
expect(calls[0].body).toEqual({ '@type': 'powerSwitchState', switchState: 'OFF' });
const coverResult = await runtime.callService?.({
domain: 'cover',
service: 'set_cover_position',
target: { entityId: 'cover.living_shutter' },
data: { position: 75 },
});
expect(coverResult?.success).toBeTrue();
expect(calls[1].body).toEqual({ '@type': 'shutterControlState', level: 0.75 });
await runtime.destroy();
});
tap.test('returns clear unsupported errors without executor for writes and pairing', async () => {
const runtime = await new BoschShcIntegration().setup({ host: '192.168.1.10', snapshot }, {});
const result = await runtime.callService?.({
domain: 'switch',
service: 'turn_on',
target: { entityId: 'switch.kitchen_plug' },
});
expect(result?.success).toBeFalse();
expect(result?.error).toContain('requires config.snapshot or an injected executor');
const pairResult = await runtime.callService?.({
domain: 'bosch_shc',
service: 'pair_client',
target: {},
data: { system_password: 'secret' },
});
expect(pairResult?.success).toBeFalse();
expect(pairResult?.error).toContain('pairing transport');
await runtime.destroy();
});
tap.test('models Bosch SHC pairing only through an injected executor', async () => {
const calls: IBoschShcModeledCommand[] = [];
const runtime = await new BoschShcIntegration().setup({
host: '192.168.1.10',
executor: async (commandArg) => {
calls.push(commandArg);
return { token: 'token:bosch-shc' };
},
}, {});
const result = await runtime.callService?.({
domain: 'bosch_shc',
service: 'pair_client',
target: {},
data: { system_password: 'secret', client_id: 'homeassistant', client_name: 'HomeAssistant' },
});
expect(result?.success).toBeTrue();
expect(calls[0].action).toEqual('pair_client');
expect(calls[0].sensitiveFields).toEqual(['systemPassword', 'certificatePem']);
expect(calls[0].body?.clientName).toEqual('HomeAssistant');
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,46 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HomeAssistantDevoloHomeNetworkIntegration, type IDevoloCommand, type IDevoloConfig } from '../../ts/integrations/devolo_home_network/index.js';
const config: IDevoloConfig = {
host: '192.168.1.20',
name: 'Office Adapter',
serialNumber: 'S12345',
features: ['led'],
switches: { leds: true },
};
tap.test('does not fake devolo command success without an injected executor', async () => {
const runtime = await new HomeAssistantDevoloHomeNetworkIntegration().setup(config, {});
const result = await runtime.callService!({
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.office_adapter_enable_leds' },
});
expect(result.success).toBeFalse();
expect(result.error || '').toInclude('not faked');
await runtime.destroy();
});
tap.test('executes devolo commands through an injected executor', async () => {
let command: IDevoloCommand | undefined;
const runtime = await new HomeAssistantDevoloHomeNetworkIntegration().setup({
...config,
commandExecutor: async (commandArg) => {
command = commandArg;
return { success: true, data: { accepted: true } };
},
}, {});
const result = await runtime.callService!({
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.office_adapter_enable_leds' },
});
expect(result.success).toBeTrue();
expect(command?.type).toEqual('device.set_leds');
expect(command?.enabled).toEqual(false);
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,36 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DevoloHomeNetworkConfigFlow } from '../../ts/integrations/devolo_home_network/index.js';
tap.test('creates devolo config from local discovery candidate', async () => {
const flow = new DevoloHomeNetworkConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'devolo_home_network',
id: 'S12345',
host: '192.168.1.20',
name: 'Office Adapter',
model: 'Magic 2 WiFi 6',
metadata: { MT: '8528', SN: 'S12345' },
}, {});
const done = await step.submit!({ password: 'local-secret' });
expect(step.kind).toEqual('form');
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.20');
expect(done.config?.serialNumber).toEqual('S12345');
expect(done.config?.metadata?.liveHttpImplemented).toEqual(false);
});
tap.test('rejects unsupported Home Control candidates in config flow', async () => {
const flow = new DevoloHomeNetworkConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'devolo_home_network',
host: '192.168.1.21',
metadata: { homeControl: true },
}, {});
expect(step.kind).toEqual('error');
});
export default tap.start();
@@ -0,0 +1,66 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createDevoloHomeNetworkDiscoveryDescriptor } from '../../ts/integrations/devolo_home_network/index.js';
tap.test('matches devolo device API mDNS records', async () => {
const descriptor = createDevoloHomeNetworkDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
type: '_dvl-deviceapi._tcp.local.',
name: 'devolo-office._dvl-deviceapi._tcp.local.',
host: '192.168.1.20',
port: 80,
txt: {
Product: 'Magic 2 WiFi 6',
SN: 'S12345',
MT: '8528',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('devolo_home_network');
expect(result.normalizedDeviceId).toEqual('S12345');
expect(result.candidate?.metadata?.MT).toEqual('8528');
});
tap.test('rejects devolo Home Control central units', async () => {
const descriptor = createDevoloHomeNetworkDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
type: '_dvl-deviceapi._tcp.local.',
host: '192.168.1.21',
txt: { Product: 'Home Control Central Unit', SN: 'HC1', MT: '2600' },
}, {});
expect(result.matched).toBeFalse();
expect(result.metadata?.homeControl).toBeTrue();
});
tap.test('matches and validates manual snapshot candidates', async () => {
const descriptor = createDevoloHomeNetworkDiscoveryDescriptor();
const manualMatcher = descriptor.getMatchers()[1];
const validator = descriptor.getValidators()[0];
const manual = await manualMatcher.matches({
host: '192.168.1.22',
product: 'devolo Magic 2 LAN',
serialNumber: 'S67890',
snapshot: {
connected: true,
device: { host: '192.168.1.22', name: 'Basement Adapter', serialNumber: 'S67890' },
plcDevices: [],
plcLinks: [],
wifiStations: [],
neighboringWifiNetworks: [],
sensors: {},
switches: {},
actions: [],
events: [],
},
}, {});
const validated = await validator.validate(manual.candidate!, {});
expect(manual.matched).toBeTrue();
expect(validated.matched).toBeTrue();
expect(validated.metadata?.liveHttpImplemented).toEqual(false);
});
export default tap.start();
@@ -0,0 +1,107 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DevoloHomeNetworkMapper, type IDevoloConfig } from '../../ts/integrations/devolo_home_network/index.js';
const config: IDevoloConfig = {
host: '192.168.1.20',
name: 'Office Adapter',
product: 'Magic 2 WiFi 6',
serialNumber: 'S12345',
macAddress: 'AA-AA-AA-AA-AA-AA',
firmwareVersion: '5.10.0',
features: ['led', 'restart', 'update', 'wifi1'],
plcDevices: [
{
macAddress: 'AA:AA:AA:AA:AA:AA',
userDeviceName: 'Office Adapter',
topology: 'LOCAL',
attachedToRouter: true,
},
{
macAddress: 'BB:BB:BB:BB:BB:BB',
userDeviceName: 'Garage Adapter',
topology: 'REMOTE',
attachedToRouter: true,
},
],
plcLinks: [
{
macAddressFrom: 'AA:AA:AA:AA:AA:AA',
macAddressTo: 'BB:BB:BB:BB:BB:BB',
rxRate: 410,
txRate: 395,
},
],
wifiStations: [
{
macAddress: 'CC:CC:CC:CC:CC:CC',
hostname: 'Kitchen Tablet',
ipAddress: '192.168.1.44',
band: 1,
vapType: 0,
rssi: -55,
},
],
neighboringWifiNetworks: [
{ ssid: 'Neighbor', bssid: 'DD:DD:DD:DD:DD:DD', band: 1, channel: 44, rssi: -70 },
],
switches: {
leds: true,
guestWifi: { enabled: false, ssid: 'Guest', key: 'secret' },
},
firmware: {
installedVersion: '5.10.0',
latestVersion: '5.11.0',
},
};
tap.test('maps devolo PLC/Wi-Fi snapshot to devices and HA-style entities', async () => {
const snapshot = DevoloHomeNetworkMapper.toSnapshot(config);
const devices = DevoloHomeNetworkMapper.toDevices(snapshot);
const entities = DevoloHomeNetworkMapper.toEntities(snapshot);
expect(snapshot.connected).toBeTrue();
expect(snapshot.sensors.connected_plc_devices).toEqual(1);
expect(snapshot.sensors.connected_wifi_clients).toEqual(1);
expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.device.s12345')).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.plc.bb_bb_bb_bb_bb_bb')).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id === 'devolo_home_network.station.cc_cc_cc_cc_cc_cc')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_connected_wifi_clients')?.state).toEqual(1);
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_plc_downlink_phy_rate_garage_adapter')?.state).toEqual(410);
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_adapter_plc_uplink_phy_rate_garage_adapter')?.state).toEqual(395);
expect(entities.find((entityArg) => entityArg.id === 'switch.office_adapter_enable_leds')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'switch.office_adapter_enable_guest_wi_fi')?.state).toEqual('off');
expect(entities.find((entityArg) => entityArg.id === 'update.office_adapter_regular_firmware')?.attributes?.latestVersion).toEqual('5.11.0');
expect(entities.find((entityArg) => entityArg.id === 'sensor.kitchen_tablet_wi_fi_band')?.state).toEqual('5');
});
tap.test('maps only represented devolo service actions to safe commands', async () => {
const snapshot = DevoloHomeNetworkMapper.toSnapshot(config);
const ledCommand = DevoloHomeNetworkMapper.commandForService(snapshot, {
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.office_adapter_enable_leds' },
});
const restartCommand = DevoloHomeNetworkMapper.commandForService(snapshot, {
domain: 'button',
service: 'press',
target: { entityId: 'button.office_adapter_restart_device' },
});
const updateCommand = DevoloHomeNetworkMapper.commandForService(snapshot, {
domain: 'update',
service: 'install',
target: { entityId: 'update.office_adapter_regular_firmware' },
});
const unsupported = DevoloHomeNetworkMapper.commandForService(DevoloHomeNetworkMapper.toSnapshot({ ...config, features: ['wifi1'], actions: [] }), {
domain: 'button',
service: 'press',
target: { entityId: 'button.office_adapter_restart_device' },
});
expect(ledCommand?.type).toEqual('device.set_leds');
expect(ledCommand?.enabled).toEqual(false);
expect(restartCommand?.type).toEqual('device.restart');
expect(updateCommand?.type).toEqual('firmware.install');
expect(unsupported).toBeUndefined();
});
export default tap.start();
+50
View File
@@ -0,0 +1,50 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HomeAssistantFritzIntegration, type IFritzCommand, type IFritzConfig } from '../../ts/integrations/fritz/index.js';
const config: IFritzConfig = {
host: '192.168.178.1',
snapshot: {
connected: true,
router: {
name: 'Home Fritz',
host: '192.168.178.1',
serialNumber: 'AABBCCDDEEFF',
actions: ['reboot'],
},
devices: [],
interfaces: [],
connection: {},
sensors: {},
wifiNetworks: [],
portForwards: [],
callDeflections: [],
},
};
tap.test('does not fake FRITZ TR-064 command success without injected executor', async () => {
const runtime = await new HomeAssistantFritzIntegration().setup(config, {});
const result = await runtime.callService!({ domain: 'fritz', service: 'reboot', target: {} });
expect(result.success).toBeFalse();
expect(result.error || '').toInclude('not faked');
await runtime.destroy();
});
tap.test('executes explicit FRITZ commands through injected executor', async () => {
let command: IFritzCommand | undefined;
const runtime = await new HomeAssistantFritzIntegration().setup({
...config,
commandExecutor: async (commandArg) => {
command = commandArg;
return { success: true, data: { accepted: true } };
},
}, {});
const result = await runtime.callService!({ domain: 'fritz', service: 'reboot', target: {} });
expect(result.success).toBeTrue();
expect(command?.type).toEqual('router.action');
expect(command?.action).toEqual('reboot');
await runtime.destroy();
});
export default tap.start();
+94
View File
@@ -0,0 +1,94 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FritzConfigFlow, createFritzDiscoveryDescriptor } from '../../ts/integrations/fritz/index.js';
tap.test('matches and validates manual FRITZ entries', async () => {
const descriptor = createFritzDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
host: '192.168.178.1',
name: 'Home Fritz',
model: 'FRITZ!Box 7590',
serialNumber: 'AABBCCDDEEFF',
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fritz');
expect(result.candidate?.port).toEqual(49000);
expect(result.normalizedDeviceId).toEqual('AABBCCDDEEFF');
const validator = descriptor.getValidators()[0];
const validation = await validator.validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
expect(validation.metadata?.liveTr064Implemented).toBeFalse();
});
tap.test('accepts snapshot-only setup and rejects unrelated manual entries', async () => {
const descriptor = createFritzDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const snapshotResult = await matcher.matches({
snapshot: {
connected: true,
router: { name: 'Snapshot Fritz', serialNumber: 'AABBCCDDEEFF', model: 'FRITZ!Box 7530' },
devices: [],
interfaces: [],
connection: {},
sensors: {},
wifiNetworks: [],
portForwards: [],
callDeflections: [],
},
}, {});
const unrelated = await matcher.matches({ name: 'Generic Switch', model: 'GS108' }, {});
expect(snapshotResult.matched).toBeTrue();
expect(snapshotResult.confidence).toEqual('certain');
expect(unrelated.matched).toBeFalse();
});
tap.test('matches Home Assistant supported FRITZ SSDP candidates', async () => {
const descriptor = createFritzDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[1];
const result = await matcher.matches({
st: 'urn:schemas-upnp-org:device:fritzbox:1',
ssdpLocation: 'http://192.168.178.1:49000/rootDesc.xml',
upnp: {
friendlyName: 'FRITZ!Box 7590',
modelName: 'FRITZ!Box 7590',
UDN: 'uuid:abcdef01-2345-6789-abcd-ef0123456789',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.candidate?.source).toEqual('ssdp');
expect(result.candidate?.host).toEqual('192.168.178.1');
expect(result.candidate?.metadata?.upstreamSupportsZeroconf).toBeFalse();
});
tap.test('matches supplied FRITZ zeroconf candidates without claiming upstream zeroconf manifest support', async () => {
const descriptor = createFritzDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[2];
const result = await matcher.matches({
host: 'fritz.box',
name: 'FRITZ!Box 7530',
model: 'FRITZ!Box 7530',
serviceType: '_http._tcp.local',
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.source).toEqual('mdns');
expect(result.candidate?.metadata?.upstreamSupportsZeroconf).toBeFalse();
});
tap.test('builds FRITZ config flow without claiming live TR-064 validation', async () => {
const flow = new FritzConfigFlow();
const step = await flow.start({ source: 'ssdp', host: '192.168.178.1', metadata: { ssl: true } }, {});
const done = await step.submit!({ host: '192.168.178.1', ssl: true, username: 'homeassistant', password: 'secret', featureDeviceTracking: true });
expect(done.kind).toEqual('done');
expect(done.config?.port).toEqual(49443);
expect(done.config?.ssl).toBeTrue();
expect(done.config?.metadata?.liveTr064Implemented).toBeFalse();
});
export default tap.start();
+138
View File
@@ -0,0 +1,138 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FritzMapper, type IFritzSnapshot } from '../../ts/integrations/fritz/index.js';
const snapshot: IFritzSnapshot = {
connected: true,
updatedAt: '2026-01-01T00:00:00.000Z',
router: {
host: '192.168.178.1',
port: 49000,
ssl: false,
name: 'Home Fritz',
model: 'FRITZ!Box 7590',
serialNumber: 'AABBCCDDEEFF',
macAddress: 'AA:BB:CC:DD:EE:FF',
firmware: '8.02',
latestFirmware: '8.03',
updateAvailable: true,
actions: ['reboot', 'reconnect', 'firmware_update'],
},
devices: [
{
mac: '11:22:33:44:55:66',
name: 'Kitchen Phone',
ipAddress: '192.168.178.45',
connected: true,
connectedTo: 'Repeater',
connectionType: 'LAN',
ssid: 'Home WiFi',
wanAccess: true,
},
],
interfaces: [
{
name: 'wan',
label: 'WAN',
connected: true,
rxBytes: 2_000_000_000,
txBytes: 1_000_000_000,
rxRateKbps: 125.5,
txRateKbps: 42.5,
},
],
connection: {
connection: 'dsl',
wanEnabled: true,
ipv6Active: true,
isConnected: true,
isLinked: true,
externalIp: '203.0.113.10',
externalIpv6: '2001:db8::1',
transmissionRate: [42_500, 125_500],
maxBitRate: [50_000_000, 250_000_000],
maxLinkedBitRate: [60_000_000, 300_000_000],
noiseMargin: [80, 90],
attenuation: [120, 130],
bytesSent: 1_000_000_000,
bytesReceived: 2_000_000_000,
cpuTemperature: 56,
},
sensors: {},
wifiNetworks: [
{ index: 1, switchName: 'Main 2.4Ghz', ssid: 'Home WiFi', enabled: true, band: '2.4Ghz' },
],
portForwards: [
{ index: 0, description: 'SSH', enabled: true, internalClient: '192.168.178.2', internalPort: 22, externalPort: 22, protocol: 'TCP', connectionType: 'WANIPConnection' },
],
callDeflections: [
{ id: 1, enabled: false, type: 'fromNumber', number: '123', deflectionToNumber: '456', mode: 'eImmediately' },
],
update: {
installedVersion: '8.02',
latestVersion: '8.03',
updateAvailable: true,
releaseUrl: 'https://example.invalid/fritzos',
},
actions: [
{ target: 'service', action: 'set_guest_wifi_password' },
{ target: 'service', action: 'dial' },
],
};
tap.test('maps FRITZ router, tracker equivalents, interfaces, traffic sensors, and controls', async () => {
const normalized = FritzMapper.toSnapshot({ snapshot });
const devices = FritzMapper.toDevices(normalized);
const entities = FritzMapper.toEntities(normalized);
expect(devices.some((deviceArg) => deviceArg.id === 'fritz.router.aa_bb_cc_dd_ee_ff')).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id === 'fritz.client.11_22_33_44_55_66')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_gb_received')?.state).toEqual(2);
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_download_throughput')?.state).toEqual(125.5);
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_fritz_wan_download')?.state).toEqual(2);
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.attributes?.nativePlatform).toEqual('device_tracker');
expect(entities.find((entityArg) => entityArg.id === 'switch.home_fritz_wi_fi_main_2_4ghz')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'switch.kitchen_phone_internet_access')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'button.kitchen_phone_wake_on_lan')?.available).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'update.home_fritz_fritz_os')?.attributes?.latestVersion).toEqual('8.03');
});
tap.test('models only represented FRITZ commands safely', async () => {
const normalized = FritzMapper.toSnapshot({ snapshot });
const rebootCommand = FritzMapper.commandForService(normalized, {
domain: 'fritz',
service: 'reboot',
target: {},
});
const wifiCommand = FritzMapper.commandForService(normalized, {
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.home_fritz_wi_fi_main_2_4ghz' },
});
const wolCommand = FritzMapper.commandForService(normalized, {
domain: 'button',
service: 'press',
target: { entityId: 'button.kitchen_phone_wake_on_lan' },
});
const serviceCommand = FritzMapper.commandForService(normalized, {
domain: 'fritz',
service: 'set_guest_wifi_password',
target: {},
data: { password: 'safe-passphrase' },
});
const invalidGuestPassword = FritzMapper.commandForService(normalized, {
domain: 'fritz',
service: 'set_guest_wifi_password',
target: {},
data: { password: 'short' },
});
expect(rebootCommand?.type).toEqual('router.action');
expect(wifiCommand?.action).toEqual('set_wifi_enabled');
expect(wifiCommand?.payload?.enabled).toBeFalse();
expect(wolCommand?.type).toEqual('client.action');
expect(wolCommand?.mac).toEqual('11:22:33:44:55:66');
expect(serviceCommand?.type).toEqual('service.action');
expect(invalidGuestPassword).toBeUndefined();
});
export default tap.start();
+63
View File
@@ -0,0 +1,63 @@
import { createServer } from 'node:http';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { GlancesClient, type IGlancesRawData } from '../../ts/integrations/glances/index.js';
const rawData: IGlancesRawData = {
system: { hostname: 'nas', os_name: 'Linux', platform: 'Linux' },
quicklook: { cpu: 12.5 },
mem: { percent: 55.5, used: 2147483648, free: 1073741824 },
fs: [{ mnt_point: '/', used: 107374182400, size: 214748364800, percent: 50 }],
network: [{ interface_name: 'eth0', rx: 600, tx: 300, time_since_update: 3, is_up: true, speed: 1073741824 }],
load: { min1: 0.12, min5: 0.24, min15: 0.42 },
processcount: { running: 3, total: 144, thread: 300, sleeping: 141 },
sensors: [{ label: 'CPU Core', type: 'temperature_core', value: 48.2 }],
};
tap.test('probes Glances v4 then v3 over local HTTP', async () => {
const requests: string[] = [];
const server = createServer((requestArg, responseArg) => {
requests.push(requestArg.url || '');
if (requestArg.url === '/api/4/all') {
responseArg.statusCode = 404;
responseArg.end('no v4 here');
return;
}
if (requestArg.url === '/api/3/all') {
expect(requestArg.headers.authorization).toEqual(`Basic ${Buffer.from('glances:secret').toString('base64')}`);
responseArg.setHeader('content-type', 'application/json');
responseArg.end(JSON.stringify(rawData));
return;
}
responseArg.statusCode = 404;
responseArg.end('not found');
});
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 GlancesClient({ host: '127.0.0.1', port, username: 'glances', password: 'secret', timeoutMs: 1000 }).getSnapshot();
expect(requests).toEqual(['/api/4/all', '/api/3/all']);
expect(snapshot.online).toBeTrue();
expect(snapshot.apiVersion).toEqual(3);
expect(snapshot.host.hostname).toEqual('nas');
expect(snapshot.sensorData.network?.eth0?.rx).toEqual(200);
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'cpu_use_percent')?.value).toEqual(12.5);
} finally {
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
}
});
tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => {
const client = new GlancesClient({});
const snapshot = await client.getSnapshot();
const result = await client.refresh();
expect(snapshot.online).toBeFalse();
expect(snapshot.sensors.length).toEqual(0);
expect(result.success).toBeFalse();
expect(result.error).toContain('endpoint');
});
export default tap.start();
@@ -0,0 +1,48 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { GlancesConfigFlow, createGlancesDiscoveryDescriptor } from '../../ts/integrations/glances/index.js';
tap.test('matches and validates manual Glances entries', async () => {
const descriptor = createGlancesDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ host: '192.168.1.70', name: 'NAS Glances' }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('glances');
expect(result.candidate?.port).toEqual(61208);
const validator = descriptor.getValidators()[0];
const validation = await validator.validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
expect(validation.normalizedDeviceId).toEqual('192.168.1.70:61208');
});
tap.test('matches HTTP API candidates and creates config flow output', async () => {
const descriptor = createGlancesDiscoveryDescriptor();
const httpMatcher = descriptor.getMatchers()[1];
const result = await httpMatcher.matches({ url: 'http://nas.local:61208/api/4/all' }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.host).toEqual('nas.local');
expect(result.candidate?.metadata?.apiVersion).toEqual(4);
const step = await new GlancesConfigFlow().start(result.candidate!, {});
const done = await step.submit!({ username: 'admin', password: 'secret', apiVersion: '4' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('nas.local');
expect(done.config?.port).toEqual(61208);
expect(done.config?.apiVersion).toEqual(4);
expect(done.config?.username).toEqual('admin');
});
tap.test('rejects candidates without Glances hints or usable data', async () => {
const descriptor = createGlancesDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({ name: 'Generic service' }, {});
expect(result.matched).toBeFalse();
const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Glances' }, {});
expect(validation.matched).toBeFalse();
});
export default tap.start();
+55
View File
@@ -0,0 +1,55 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { GlancesMapper, type IGlancesRawData } from '../../ts/integrations/glances/index.js';
const rawData: IGlancesRawData = {
system: { hostname: 'nas', os_name: 'Linux', platform: 'Linux', hr_name: 'Ubuntu 24.04' },
quicklook: { cpu: 12.5 },
percpu: [{ cpu_number: 0, total: 10 }],
mem: { percent: 55.5, used: 2147483648, free: 1073741824 },
memswap: { percent: 10, used: 1073741824, free: 9663676416 },
fs: [{ mnt_point: '/', used: 107374182400, size: 214748364800, free: 107374182400, percent: 50 }],
diskio: [{ disk_name: 'sda', read_bytes: 1000, write_bytes: 2000, time_since_update: 2 }],
network: [{ interface_name: 'eth0', bytes_recv_rate_per_sec: 1234, bytes_sent_rate_per_sec: 5678, is_up: true, speed: 1073741824 }],
load: { min1: 0.12, min5: 0.24, min15: 0.42 },
processcount: { running: 3, total: 144, thread: 300, sleeping: 141 },
sensors: [{ label: 'CPU Core', type: 'temperature_core', value: 48.2 }],
};
tap.test('maps raw Glances API snapshots to CPU memory disk network load temperature and process sensors', async () => {
const snapshot = GlancesMapper.toSnapshot({ config: { name: 'NAS' }, rawData, apiVersion: 4, online: true, source: 'manual' });
const entities = GlancesMapper.toEntities(snapshot);
const devices = GlancesMapper.toDevices(snapshot);
expect(snapshot.sensorData.mem?.memory_use).toEqual(2048);
expect(snapshot.sensorData.fs?.['/']?.disk_size).toEqual(200);
expect(snapshot.sensorData.network?.eth0?.rx).toEqual(1234);
expect(devices[0].id).toEqual('glances.host.nas');
expect(entities.find((entityArg) => entityArg.attributes?.key === 'cpu_use_percent')?.state).toEqual(12.5);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'memory_use_percent')?.state).toEqual(55.5);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'disk_glances_use_percent')?.state).toEqual(50);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'network_eth0_rx')?.state).toEqual(1234);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'processor_load')?.state).toEqual(0.42);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'sensor_cpu_core_temperature_core')?.attributes?.deviceClass).toEqual('temperature');
expect(entities.find((entityArg) => entityArg.attributes?.key === 'process_running')?.state).toEqual(3);
});
tap.test('maps manual HA sensor data without inventing absent values', async () => {
const snapshot = GlancesMapper.toSnapshot({
config: {
name: 'Manual Glances',
haSensorData: {
cpu: { cpu_use_percent: 7 },
processcount: { process_total: 9 },
},
},
online: true,
source: 'manual',
});
const entities = GlancesMapper.toEntities(snapshot);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'cpu_use_percent')?.state).toEqual(7);
expect(entities.find((entityArg) => entityArg.attributes?.key === 'process_total')?.state).toEqual(9);
expect(entities.some((entityArg) => entityArg.attributes?.key === 'memory_use_percent')).toBeFalse();
});
export default tap.start();
+31
View File
@@ -0,0 +1,31 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HeosConfigFlow } from '../../ts/integrations/heos/index.js';
tap.test('creates HEOS config from discovered host and account options', async () => {
const flow = new HeosConfigFlow();
const step = await flow.start({
source: 'ssdp',
integrationDomain: 'heos',
host: '192.168.1.80',
name: 'Living Room HEOS',
manufacturer: 'Denon',
model: 'HEOS 7',
serialNumber: 'HEOS12345',
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({ username: 'listener@example.com', password: 'secret' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.80');
expect(done.config?.port).toEqual(1255);
expect(done.config?.username).toEqual('listener@example.com');
});
tap.test('requires a HEOS host for manual setup', async () => {
const flow = new HeosConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'heos' }, {});
const result = await step.submit!({});
expect(result.kind).toEqual('error');
});
export default tap.start();
+56
View File
@@ -0,0 +1,56 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createHeosDiscoveryDescriptor } from '../../ts/integrations/heos/index.js';
tap.test('matches Home Assistant HEOS SSDP records', async () => {
const descriptor = createHeosDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
st: 'urn:schemas-denon-com:device:ACT-Denon:1',
usn: 'uuid:heos-123::urn:schemas-denon-com:device:ACT-Denon:1',
location: 'http://192.168.1.80:60006/upnp/desc/aios_device/aios_device.xml',
upnp: {
manufacturer: 'Denon',
modelName: 'HEOS 7',
serialNumber: 'HEOS12345',
friendlyName: 'Living Room HEOS',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.normalizedDeviceId).toEqual('HEOS12345');
expect(result.candidate?.host).toEqual('192.168.1.80');
expect(result.candidate?.port).toEqual(1255);
});
tap.test('matches HEOS zeroconf and manual candidates', async () => {
const descriptor = createHeosDiscoveryDescriptor();
const mdns = await descriptor.getMatchers()[1].matches({
name: 'Kitchen HEOS',
type: '_heos-audio._tcp.local.',
host: 'kitchen-heos.local',
port: 10101,
txt: { model: 'HEOS 5', serial: 'HEOS5678' },
}, {});
expect(mdns.matched).toBeTrue();
expect(mdns.candidate?.port).toEqual(1255);
const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.81', name: 'Manual HEOS' }, {});
expect(manual.matched).toBeTrue();
expect(manual.candidate?.host).toEqual('192.168.1.81');
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
expect(validation.matched).toBeTrue();
});
tap.test('rejects unrelated SSDP records', async () => {
const descriptor = createHeosDiscoveryDescriptor();
const result = await descriptor.getMatchers()[0].matches({
st: 'urn:schemas-upnp-org:device:Printer:1',
location: 'http://192.168.1.90/device.xml',
upnp: { manufacturer: 'Brother', modelName: 'Printer' },
}, {});
expect(result.matched).toBeFalse();
});
export default tap.start();
+104
View File
@@ -0,0 +1,104 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HeosMapper, type IHeosSnapshot } from '../../ts/integrations/heos/index.js';
const snapshot: IHeosSnapshot = {
system: {
host: '192.168.1.80',
currentHost: '192.168.1.80',
signedInUsername: 'listener@example.com',
isSignedIn: true,
},
players: [{
name: 'Living Room',
playerId: 101,
model: 'Denon HEOS 7',
serial: 'LR123',
version: '3.40.0',
ipAddress: '192.168.1.80',
network: 'wired',
state: 'play',
volume: 45,
muted: false,
repeat: 'on_all',
shuffle: true,
groupId: 201,
available: true,
nowPlayingMedia: {
type: 'station',
station: 'Jazz Radio',
albumId: 'fav-jazz',
mediaId: 'station-1',
sourceId: 3,
duration: 180000,
currentPosition: 30000,
supportedControls: ['play', 'pause', 'stop'],
},
}, {
name: 'Kitchen',
playerId: 102,
model: 'Denon HEOS 5',
serial: 'KT456',
version: '3.40.0',
ipAddress: '192.168.1.81',
network: 'wifi',
state: 'pause',
volume: 25,
muted: true,
groupId: 201,
available: true,
nowPlayingMedia: {
type: 'station',
station: 'HDMI ARC',
mediaId: 'inputs/hdmi_arc_1',
sourceId: 1027,
},
}],
groups: [{
name: 'Living Room + Kitchen',
groupId: 201,
leadPlayerId: 101,
memberPlayerIds: [102],
volume: 40,
muted: false,
}],
favorites: {
1: { sourceId: 1028, name: 'Jazz Radio', type: 'station', mediaId: 'fav-jazz', playable: true },
},
inputSources: [{
sourceId: 1027,
name: 'HDMI ARC',
type: 'station',
mediaId: 'inputs/hdmi_arc_1',
playable: true,
}],
};
tap.test('maps HEOS players and system snapshot to devices', async () => {
const devices = HeosMapper.toDevices(snapshot);
expect(devices.some((deviceArg) => deviceArg.id === 'heos.system.192_168_1_80')).toBeTrue();
const player = devices.find((deviceArg) => deviceArg.id === 'heos.player.101');
expect(player?.manufacturer).toEqual('Denon');
expect(player?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue();
});
tap.test('maps HEOS players groups sources and media entities', async () => {
const entities = HeosMapper.toEntities(snapshot);
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
const kitchen = entities.find((entityArg) => entityArg.id === 'media_player.kitchen');
const sources = entities.find((entityArg) => entityArg.id === 'sensor.heos_system_sources');
const groups = entities.find((entityArg) => entityArg.id === 'sensor.heos_system_groups');
const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_heos_media');
expect(player?.state).toEqual('playing');
expect(player?.attributes?.volumeLevel).toEqual(0.45);
expect(player?.attributes?.source).toEqual('Jazz Radio');
expect(player?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']);
expect(player?.attributes?.mediaDuration).toEqual(180);
expect(player?.attributes?.mediaPosition).toEqual(30);
expect(kitchen?.attributes?.source).toEqual('HDMI ARC');
expect(sources?.state).toEqual(2);
expect(groups?.state).toEqual(1);
expect(media?.state).toEqual('Jazz Radio');
});
export default tap.start();
+74
View File
@@ -0,0 +1,74 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { HeosIntegration, type IHeosRawCommandRequest, type IHeosSnapshot } from '../../ts/integrations/heos/index.js';
const snapshot: IHeosSnapshot = {
system: { host: '192.168.1.80', currentHost: '192.168.1.80' },
players: [{
name: 'Living Room',
playerId: 101,
model: 'Denon HEOS 7',
state: 'pause',
volume: 20,
groupId: 201,
available: true,
}, {
name: 'Kitchen',
playerId: 102,
model: 'Denon HEOS 5',
state: 'pause',
volume: 30,
groupId: 201,
available: true,
}],
groups: [{ name: 'Downstairs', groupId: 201, leadPlayerId: 101, memberPlayerIds: [102] }],
favorites: {
1: { sourceId: 1028, name: 'Jazz Radio', type: 'station', mediaId: 'fav-jazz', playable: true },
},
inputSources: [{ sourceId: 1027, name: 'HDMI ARC', type: 'station', mediaId: 'inputs/hdmi_arc_1', playable: true }],
};
tap.test('models HEOS media player commands through an executor', async () => {
const commands: IHeosRawCommandRequest[] = [];
const runtime = await new HeosIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { command: requestArg.command, result: true, message: {} };
},
},
}, {});
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } });
const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'Jazz Radio' } });
const join = await runtime.callService!({ domain: 'media_player', service: 'join', target: { entityId: 'media_player.living_room' }, data: { group_members: ['media_player.kitchen'] } });
const groupVolume = await runtime.callService!({ domain: 'heos', service: 'group_volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.5 } });
expect(play.success).toBeTrue();
expect(volume.success).toBeTrue();
expect(source.success).toBeTrue();
expect(join.success).toBeTrue();
expect(groupVolume.success).toBeTrue();
expect(commands.map((commandArg) => commandArg.command)).toEqual([
'player/set_play_state',
'player/set_volume',
'browse/play_preset',
'group/set_group',
'group/set_volume',
]);
expect(commands[0].parameters).toEqual({ pid: 101, state: 'play' });
expect(commands[1].parameters).toEqual({ pid: 101, level: 35 });
expect(commands[2].parameters).toEqual({ pid: 101, preset: 1 });
expect(commands[3].parameters).toEqual({ pid: '101,102' });
expect(commands[4].parameters).toEqual({ gid: 201, level: 50 });
});
tap.test('does not report live command success for static snapshots without transport', async () => {
const runtime = await new HeosIntegration().setup({ snapshot }, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
expect(result.success).toBeFalse();
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
});
export default tap.start();
+135
View File
@@ -0,0 +1,135 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { IppClient, IppIntegration } from '../../ts/integrations/ipp/index.js';
tap.test('reads IPP printer attributes with a native Get-Printer-Attributes request', async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body: Buffer }> = [];
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
calls.push({ url: String(urlArg), method: initArg?.method, body: Buffer.from(initArg?.body as ArrayBuffer) });
return new Response(arrayBuffer(ippResponse([
stringAttribute(0x42, 'printer-name', ['TCP Printer']),
stringAttribute(0x41, 'printer-info', ['Ready over IPP']),
stringAttribute(0x41, 'printer-make-and-model', ['Brother HL-L2350DW']),
stringAttribute(0x41, 'printer-device-id', ['MFG:Brother;MDL:HL-L2350DW;CMD:PCL;SERIALNUMBER:BR123;']),
stringAttribute(0x45, 'printer-uri-supported', ['ipp://printer.local:631/ipp/print']),
enumAttribute('printer-state', [4]),
stringAttribute(0x41, 'printer-state-message', ['Printing']),
stringAttribute(0x44, 'printer-state-reasons', ['none']),
booleanAttribute('printer-is-accepting-jobs', true),
integerAttribute('queued-job-count', 1),
stringAttribute(0x42, 'marker-names', ['Black Toner']),
stringAttribute(0x44, 'marker-types', ['toner-cartridge']),
integerAttribute('marker-levels', 73),
])), { status: 200, headers: { 'content-type': 'application/ipp' } });
}) as typeof globalThis.fetch;
try {
const snapshot = await new IppClient({ host: 'printer.local', port: 631, basePath: '/ipp/print', timeoutMs: 1000 }).getSnapshot();
expect(snapshot.online).toBeTrue();
expect(snapshot.printer.manufacturer).toEqual('Brother');
expect(snapshot.printer.serialNumber).toEqual('BR123');
expect(snapshot.status.printerState).toEqual('printing');
expect(snapshot.status.queuedJobCount).toEqual(1);
expect(snapshot.markers[0].level).toEqual(73);
expect(calls[0].url).toEqual('http://printer.local:631/ipp/print');
expect(calls[0].method).toEqual('POST');
expect(calls[0].body.readUInt16BE(2)).toEqual(0x000b);
expect(calls[0].body.includes(Buffer.from('printer-uri'))).toBeTrue();
} finally {
globalThis.fetch = originalFetch;
}
});
tap.test('read-only IPP runtime exposes snapshot and refuses unsupported writes', async () => {
const runtime = await new IppIntegration().setup({
snapshot: {
printer: { id: 'snapshot-printer', name: 'Snapshot Printer' },
status: { printerState: 'idle', stateReasons: [] },
markers: [{ index: 0, name: 'Black Toner', kind: 'toner', level: 55 }],
jobs: [],
online: true,
},
}, {});
const snapshotResult = await runtime.callService?.({ domain: 'ipp', service: 'snapshot', target: {} });
expect(snapshotResult?.success).toBeTrue();
const unsupported = await runtime.callService?.({ domain: 'switch', service: 'turn_on', target: {} });
expect(unsupported?.success).toBeFalse();
expect((await runtime.entities()).find((entityArg) => entityArg.id === 'sensor.black_toner')?.state).toEqual(55);
await runtime.destroy();
});
tap.test('refresh reports failure for configs without host, client, attributes, or snapshot', async () => {
const runtime = await new IppIntegration().setup({ name: 'No Source Printer' }, {});
const result = await runtime.callService?.({ domain: 'ipp', service: 'refresh', target: {} });
expect(result?.success).toBeFalse();
await runtime.destroy();
});
tap.test('refresh reports failed live IPP reads instead of faking success', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error('connection refused');
}) as typeof globalThis.fetch;
try {
const runtime = await new IppIntegration().setup({ host: 'printer.local', timeoutMs: 1000 }, {});
const result = await runtime.callService?.({ domain: 'ipp', service: 'refresh', target: {} });
expect(result?.success).toBeFalse();
expect(result?.error).toEqual('connection refused');
await runtime.destroy();
} finally {
globalThis.fetch = originalFetch;
}
});
const ippResponse = (attributesArg: Buffer[]): Buffer => {
return Buffer.concat([
Buffer.from([0x01, 0x01]),
uint16(0x0000),
uint32(1),
Buffer.from([0x04]),
...attributesArg,
Buffer.from([0x03]),
]);
};
const stringAttribute = (tagArg: number, nameArg: string, valuesArg: string[]): Buffer => {
return Buffer.concat(valuesArg.map((valueArg, indexArg) => value(tagArg, indexArg === 0 ? nameArg : '', Buffer.from(valueArg, 'utf8'))));
};
const integerAttribute = (nameArg: string, valueArg: number): Buffer => {
const buffer = Buffer.alloc(4);
buffer.writeInt32BE(valueArg, 0);
return value(0x21, nameArg, buffer);
};
const enumAttribute = (nameArg: string, valuesArg: number[]): Buffer => {
return Buffer.concat(valuesArg.map((valueArg, indexArg) => {
const buffer = Buffer.alloc(4);
buffer.writeInt32BE(valueArg, 0);
return value(0x23, indexArg === 0 ? nameArg : '', buffer);
}));
};
const booleanAttribute = (nameArg: string, valueArg: boolean): Buffer => value(0x22, nameArg, Buffer.from([valueArg ? 1 : 0]));
const value = (tagArg: number, nameArg: string, valueArg: Buffer): Buffer => {
const name = Buffer.from(nameArg, 'utf8');
return Buffer.concat([Buffer.from([tagArg]), uint16(name.length), name, uint16(valueArg.length), valueArg]);
};
const uint16 = (valueArg: number): Buffer => {
const buffer = Buffer.alloc(2);
buffer.writeUInt16BE(valueArg, 0);
return buffer;
};
const uint32 = (valueArg: number): Buffer => {
const buffer = Buffer.alloc(4);
buffer.writeUInt32BE(valueArg, 0);
return buffer;
};
const arrayBuffer = (bufferArg: Buffer): ArrayBuffer => bufferArg.buffer.slice(bufferArg.byteOffset, bufferArg.byteOffset + bufferArg.byteLength) as ArrayBuffer;
export default tap.start();
+58
View File
@@ -0,0 +1,58 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { IppConfigFlow, createIppDiscoveryDescriptor } from '../../ts/integrations/ipp/index.js';
tap.test('matches IPP and IPPS mDNS printer records', async () => {
const descriptor = createIppDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ipp-mdns-match');
const result = await matcher!.matches({
type: '_ipps._tcp.local.',
name: 'Office Printer._ipps._tcp.local.',
host: 'office-printer.local',
port: 631,
txt: {
rp: 'ipp/print',
UUID: 'printer-uuid-1',
ty: 'HP Color LaserJet Pro',
usb_MFG: 'HP',
usb_MDL: 'Color LaserJet Pro',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.normalizedDeviceId).toEqual('printer-uuid-1');
expect(result.candidate?.integrationDomain).toEqual('ipp');
expect(result.candidate?.metadata?.basePath).toEqual('/ipp/print');
expect(result.candidate?.metadata?.tls).toBeTrue();
const validator = descriptor.getValidators()[0];
const validation = await validator.validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
expect(validation.confidence).toEqual('certain');
});
tap.test('matches manual IPP URLs and carries defaults into config flow', async () => {
const descriptor = createIppDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ipp-manual-match');
const match = await matcher!.matches({ host: 'ipps://printer.local:631/ipp/print', name: 'Manual Printer' }, {});
expect(match.matched).toBeTrue();
expect(match.candidate?.host).toEqual('printer.local');
expect(match.candidate?.metadata?.basePath).toEqual('/ipp/print');
expect(match.candidate?.metadata?.tls).toBeTrue();
const step = await new IppConfigFlow().start(match.candidate!, {});
const done = await step.submit!({ host: 'printer.local' });
expect(done.kind).toEqual('done');
expect(done.config?.port).toEqual(631);
expect(done.config?.basePath).toEqual('/ipp/print');
expect(done.config?.tls).toBeTrue();
});
tap.test('rejects IPP-looking candidates without a usable source', async () => {
const descriptor = createIppDiscoveryDescriptor();
const validator = descriptor.getValidators()[0];
const validation = await validator.validate({ source: 'manual', integrationDomain: 'ipp', name: 'Printer without host' }, {});
expect(validation.matched).toBeFalse();
expect(validation.reason).toEqual('IPP candidate lacks host, snapshot, attributes, or client information.');
});
export default tap.start();
+74
View File
@@ -0,0 +1,74 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { IppClient, IppMapper, type IIppAttributeRecord, type IIppSnapshot } from '../../ts/integrations/ipp/index.js';
const attributes: IIppAttributeRecord = {
'printer-name': 'Office Printer',
'printer-info': 'Office color printer',
'printer-location': 'Office',
'printer-make-and-model': 'HP Color LaserJet Pro',
'printer-device-id': 'MFG:HP;MDL:Color LaserJet Pro;CMD:PCL,POSTSCRIPT;SERIALNUMBER:CN123456;',
'printer-uuid': 'urn:uuid:printer-1',
'printer-state': 3,
'printer-state-message': 'Ready',
'printer-state-reasons': ['none'],
'printer-is-accepting-jobs': true,
'queued-job-count': 2,
'printer-up-time': 3600,
'printer-current-time': '2026-01-01T01:00:00.000Z',
'marker-names': ['Black Toner', 'Cyan Ink'],
'marker-types': ['toner-cartridge', 'ink-cartridge'],
'marker-colors': ['black', 'cyan'],
'marker-levels': [88, 42],
'marker-low-levels': [5, 10],
'marker-high-levels': [100, 100],
'job-id': [12],
'job-name': ['Test Page'],
'job-state': [5],
'job-originating-user-name': ['phil'],
};
tap.test('maps IPP attributes to printer device, marker, status, and job sensors', async () => {
const snapshot = IppClient.attributesToSnapshot(attributes, { host: 'printer.local', port: 631, basePath: '/ipp/print' }, true);
expect(snapshot.printer.manufacturer).toEqual('HP');
expect(snapshot.printer.serialNumber).toEqual('CN123456');
expect(snapshot.status.printerState).toEqual('idle');
expect(snapshot.status.bootedAt).toEqual('2026-01-01T00:00:00.000Z');
expect(snapshot.markers[0].kind).toEqual('toner');
expect(snapshot.markers[1].kind).toEqual('ink');
expect(snapshot.jobs[0].state).toEqual('processing');
const devices = IppMapper.toDevices(snapshot);
const entities = IppMapper.toEntities(snapshot);
expect(devices[0].id).toEqual('ipp.printer.urn_uuid_printer_1');
expect(devices[0].features.some((featureArg) => featureArg.id === 'marker_0')).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_printer_status')?.state).toEqual('idle');
expect(entities.find((entityArg) => entityArg.id === 'sensor.black_toner')?.attributes?.markerKind).toEqual('toner');
expect(entities.find((entityArg) => entityArg.id === 'sensor.cyan_ink')?.state).toEqual(42);
expect(entities.find((entityArg) => entityArg.id === 'sensor.office_printer_queued_jobs')?.state).toEqual(2);
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.office_printer_accepting_jobs')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'sensor.test_page')?.attributes?.owner).toEqual('phil');
});
tap.test('keeps hostless IPP runtime snapshots offline instead of faking live success', async () => {
const snapshot = await new IppClient({ name: 'Hostless Printer' }).getSnapshot();
expect(snapshot.online).toBeFalse();
expect(snapshot.source).toEqual('runtime');
expect(await new IppClient({ name: 'Hostless Printer' }).ping()).toBeFalse();
});
tap.test('maps offline snapshots without inventing marker or job values', async () => {
const snapshot: IIppSnapshot = {
printer: { id: 'offline-printer', name: 'Offline Printer' },
status: { printerState: 'unknown', stateReasons: [] },
markers: [],
jobs: [],
online: false,
updatedAt: '2026-01-01T00:00:00.000Z',
};
const entities = IppMapper.toEntities(snapshot);
expect(entities.find((entityArg) => entityArg.id === 'sensor.offline_printer_status')?.available).toBeFalse();
expect(entities.some((entityArg) => entityArg.id.includes('marker'))).toBeFalse();
expect(entities.some((entityArg) => entityArg.id.includes('job'))).toBeFalse();
});
export default tap.start();