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