Add native local infrastructure integrations
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user