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();
+12
View File
@@ -15,16 +15,22 @@ import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js';
import { AsuswrtIntegration } from './integrations/asuswrt/index.js'; import { AsuswrtIntegration } from './integrations/asuswrt/index.js';
import { BleboxIntegration } from './integrations/blebox/index.js'; import { BleboxIntegration } from './integrations/blebox/index.js';
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js'; import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
import { BoschShcIntegration } from './integrations/bosch_shc/index.js';
import { BraviatvIntegration } from './integrations/braviatv/index.js'; import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js'; import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js'; import { CastIntegration } from './integrations/cast/index.js';
import { DeconzIntegration } from './integrations/deconz/index.js'; import { DeconzIntegration } from './integrations/deconz/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js'; import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js'; import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
import { DsmrIntegration } from './integrations/dsmr/index.js'; import { DsmrIntegration } from './integrations/dsmr/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js';
import { FritzIntegration } from './integrations/fritz/index.js';
import { GlancesIntegration } from './integrations/glances/index.js';
import { HeosIntegration } from './integrations/heos/index.js';
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
import { HomematicIntegration } from './integrations/homematic/index.js'; import { HomematicIntegration } from './integrations/homematic/index.js';
import { IppIntegration } from './integrations/ipp/index.js';
import { JellyfinIntegration } from './integrations/jellyfin/index.js'; import { JellyfinIntegration } from './integrations/jellyfin/index.js';
import { KnxIntegration } from './integrations/knx/index.js'; import { KnxIntegration } from './integrations/knx/index.js';
import { KodiIntegration } from './integrations/kodi/index.js'; import { KodiIntegration } from './integrations/kodi/index.js';
@@ -71,17 +77,23 @@ export const integrations = [
new AxisIntegration(), new AxisIntegration(),
new BleboxIntegration(), new BleboxIntegration(),
new BluetoothLeTrackerIntegration(), new BluetoothLeTrackerIntegration(),
new BoschShcIntegration(),
new BraviatvIntegration(), new BraviatvIntegration(),
new BroadlinkIntegration(), new BroadlinkIntegration(),
new CastIntegration(), new CastIntegration(),
new DeconzIntegration(), new DeconzIntegration(),
new DenonavrIntegration(), new DenonavrIntegration(),
new DevoloHomeNetworkIntegration(),
new DlnaDmrIntegration(), new DlnaDmrIntegration(),
new DsmrIntegration(), new DsmrIntegration(),
new EsphomeIntegration(), new EsphomeIntegration(),
new FritzIntegration(),
new GlancesIntegration(),
new HeosIntegration(),
new HomekitControllerIntegration(), new HomekitControllerIntegration(),
new HomematicIntegration(), new HomematicIntegration(),
new HueIntegration(), new HueIntegration(),
new IppIntegration(),
new JellyfinIntegration(), new JellyfinIntegration(),
new KnxIntegration(), new KnxIntegration(),
new KodiIntegration(), new KodiIntegration(),
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,116 @@
import type {
IBoschShcCommandContext,
IBoschShcConfig,
IBoschShcModeledCommand,
IBoschShcPairClientRequest,
IBoschShcPublicInformation,
IBoschShcSnapshot,
} from './bosch_shc.types.js';
export class BoschShcUnsupportedLiveOperationError extends Error {
constructor(actionArg: string) {
super(`Bosch SHC ${actionArg} requires config.snapshot or an injected executor. This native TypeScript port does not implement live TLS, certificate generation, or pairing transport.`);
this.name = 'BoschShcUnsupportedLiveOperationError';
}
}
export class BoschShcClient {
private readonly snapshot?: IBoschShcSnapshot;
constructor(private readonly config: IBoschShcConfig) {
this.snapshot = config.snapshot ? this.clone(config.snapshot) : undefined;
}
public async getSnapshot(): Promise<IBoschShcSnapshot> {
if (this.snapshot) {
return this.normalizeSnapshot(this.clone(this.snapshot), 'snapshot');
}
const result = await this.execute({
action: 'read_snapshot',
method: 'GET',
path: '/smarthome',
reason: 'read_snapshot',
});
if (!this.isSnapshot(result)) {
throw new Error('Bosch SHC snapshot executor must return an IBoschShcSnapshot object for read_snapshot.');
}
return this.normalizeSnapshot(result, 'executor');
}
public async getPublicInformation(): Promise<IBoschShcPublicInformation> {
if (this.snapshot?.information) {
return this.clone(this.snapshot.information);
}
const result = await this.execute({
action: 'get_public_information',
method: 'GET',
path: '/smarthome/public/information',
reason: 'get_public_information',
});
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
throw new Error('Bosch SHC public information executor must return an object.');
}
return result as IBoschShcPublicInformation;
}
public async pairClient(requestArg: IBoschShcPairClientRequest): Promise<unknown> {
if (!requestArg.systemPassword) {
throw new Error('Bosch SHC pairing requires the controller system password.');
}
return this.execute({
action: 'pair_client',
method: 'POST',
path: '/smarthome/clients',
reason: 'pair_client',
body: {
clientId: requestArg.clientId || this.config.host || 'smarthome_exchange',
clientName: requestArg.clientName || 'smarthome.exchange',
certificatePem: requestArg.certificatePem,
systemPassword: requestArg.systemPassword,
},
sensitiveFields: ['systemPassword', 'certificatePem'],
});
}
public async executeCommand(commandArg: IBoschShcModeledCommand, snapshotArg?: IBoschShcSnapshot): Promise<unknown> {
return this.execute(commandArg, snapshotArg);
}
public async destroy(): Promise<void> {}
private async execute(commandArg: IBoschShcModeledCommand, snapshotArg?: IBoschShcSnapshot): Promise<unknown> {
const executor = this.config.executor;
if (!executor) {
throw new BoschShcUnsupportedLiveOperationError(commandArg.action);
}
const context: IBoschShcCommandContext = {
config: this.config,
snapshot: snapshotArg || this.snapshot,
};
if (typeof executor === 'function') {
return executor(commandArg, context);
}
return executor.execute(commandArg, context);
}
private normalizeSnapshot(snapshotArg: IBoschShcSnapshot, sourceArg: IBoschShcSnapshot['source']): IBoschShcSnapshot {
return {
...snapshotArg,
devices: snapshotArg.devices || [],
host: snapshotArg.host || this.config.host || snapshotArg.information?.shcIpAddress,
uniqueId: snapshotArg.uniqueId || this.config.uniqueId || snapshotArg.information?.macAddress,
name: snapshotArg.name || this.config.name || this.config.hostname || snapshotArg.information?.shcIpAddress,
online: snapshotArg.online !== false,
source: snapshotArg.source || sourceArg,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private isSnapshot(valueArg: unknown): valueArg is IBoschShcSnapshot {
return typeof valueArg === 'object' && valueArg !== null && 'devices' in valueArg;
}
private clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
@@ -0,0 +1,84 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IBoschShcConfig, IBoschShcSnapshot } from './bosch_shc.types.js';
export class BoschShcConfigFlow implements IConfigFlow<IBoschShcConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBoschShcConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Set up Bosch SHC',
description: 'Configure a local Bosch Smart Home Controller. Use an existing certificate/key or a snapshot; native pairing and live TLS transport require an injected executor.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'sslCertificate', label: 'Client certificate path or PEM', type: 'text' },
{ name: 'sslKey', label: 'Client key path or PEM', type: 'password' },
{ name: 'token', label: 'Registration token', type: 'text' },
{ name: 'hostname', label: 'Controller hostname from token', type: 'text' },
{ name: 'password', label: 'System password for executor-based pairing', type: 'password' },
{ name: 'snapshotJson', label: 'Controller snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.finish(valuesArg, candidateArg),
};
}
private async finish(valuesArg: Record<string, unknown>, candidateArg: IDiscoveryCandidate): Promise<IConfigFlowStep<IBoschShcConfig>> {
const host = this.stringValue(valuesArg.host) || candidateArg.host;
if (!host || host.includes('://')) {
return { kind: 'error', title: 'Invalid Bosch SHC host', error: 'Bosch SHC host must be a hostname or IP address without a URL scheme.' };
}
const sslCertificate = this.stringValue(valuesArg.sslCertificate);
const sslKey = this.stringValue(valuesArg.sslKey);
if (Boolean(sslCertificate) !== Boolean(sslKey)) {
return { kind: 'error', title: 'Incomplete Bosch SHC TLS credentials', error: 'Bosch SHC certificate and key must be provided together.' };
}
const snapshot = this.jsonValue<IBoschShcSnapshot>(valuesArg.snapshotJson)
|| this.objectValue<IBoschShcSnapshot>(candidateArg.metadata?.snapshot);
const password = this.stringValue(valuesArg.password);
if (password && !sslCertificate && !snapshot) {
return {
kind: 'error',
title: 'Bosch SHC pairing requires an executor',
error: 'Native Bosch SHC pairing/certificate generation is not implemented here. Inject an executor/client or provide existing certificate/key credentials.',
};
}
return {
kind: 'done',
title: 'Bosch SHC configured',
config: {
host,
sslCertificate,
sslKey,
token: this.stringValue(valuesArg.token),
hostname: this.stringValue(valuesArg.hostname),
uniqueId: candidateArg.id || snapshot?.uniqueId || snapshot?.information?.macAddress,
name: candidateArg.name || snapshot?.name,
snapshot: snapshot ? { ...snapshot, host: snapshot.host || host } : undefined,
},
};
}
private jsonValue<TValue>(valueArg: unknown): TValue | undefined {
if (typeof valueArg !== 'string' || !valueArg.trim()) return undefined;
try {
const parsed = JSON.parse(valueArg) as unknown;
return this.isRecord(parsed) ? parsed as TValue : undefined;
} catch {
return undefined;
}
}
private objectValue<TValue>(valueArg: unknown): TValue | undefined {
return this.isRecord(valueArg) ? valueArg as TValue : undefined;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -1,28 +1,96 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { BoschShcClient } from './bosch_shc.classes.client.js';
import { BoschShcConfigFlow } from './bosch_shc.classes.configflow.js';
import { createBoschShcDiscoveryDescriptor } from './bosch_shc.discovery.js';
import { BoschShcMapper } from './bosch_shc.mapper.js';
import type { IBoschShcConfig, IBoschShcPairClientRequest } from './bosch_shc.types.js';
export class HomeAssistantBoschShcIntegration extends DescriptorOnlyIntegration { export class BoschShcIntegration extends BaseIntegration<IBoschShcConfig> {
constructor() { public readonly domain = 'bosch_shc';
super({ public readonly displayName = 'Bosch SHC';
domain: "bosch_shc", public readonly status = 'control-runtime' as const;
displayName: "Bosch SHC", public readonly discoveryDescriptor = createBoschShcDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new BoschShcConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/bosch_shc", upstreamPath: 'homeassistant/components/bosch_shc',
"upstreamDomain": "bosch_shc", upstreamDomain: 'bosch_shc',
"integrationType": "hub", documentation: 'https://www.home-assistant.io/integrations/bosch_shc',
"iotClass": "local_push", integrationType: 'hub',
"requirements": [ iotClass: 'local_push',
"boschshcpy==0.2.107" requirements: ['boschshcpy==0.2.107'],
], dependencies: [] as string[],
"dependencies": [], afterDependencies: ['zeroconf'],
"afterDependencies": [ codeowners: ['@tschamm'],
"zeroconf" zeroconf: [{ name: 'bosch shc*', type: '_http._tcp.local.' }],
], nativeLiveTransportImplemented: false,
"codeowners": [ };
"@tschamm"
] public async setup(configArg: IBoschShcConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}, void contextArg;
}); return new BoschShcRuntime(new BoschShcClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantBoschShcIntegration extends BoschShcIntegration {}
class BoschShcRuntime implements IIntegrationRuntime {
public domain = 'bosch_shc';
constructor(private readonly client: BoschShcClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return BoschShcMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BoschShcMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'bosch_shc') {
return await this.callBoschService(requestArg);
}
const snapshot = await this.client.getSnapshot();
const command = BoschShcMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Bosch SHC service mapping: ${requestArg.domain}.${requestArg.service}` };
}
if ('error' in command) {
return { success: false, error: command.error };
}
const data = await this.client.executeCommand(command, snapshot);
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callBoschService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (!['pair_client', 'register_client'].includes(requestArg.service)) {
return { success: false, error: `Unsupported Bosch SHC integration service: ${requestArg.service}` };
}
const systemPassword = requestArg.data?.system_password || requestArg.data?.password;
if (typeof systemPassword !== 'string' || !systemPassword) {
return { success: false, error: 'Bosch SHC pairing requires data.system_password.' };
}
const request: IBoschShcPairClientRequest = {
systemPassword,
clientId: typeof requestArg.data?.client_id === 'string' ? requestArg.data.client_id : undefined,
clientName: typeof requestArg.data?.client_name === 'string' ? requestArg.data.client_name : undefined,
certificatePem: typeof requestArg.data?.certificate_pem === 'string' ? requestArg.data.certificate_pem : undefined,
};
const data = await this.client.pairClient(request);
return { success: true, data };
} }
} }
@@ -0,0 +1,131 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IBoschShcManualEntry, IBoschShcMdnsRecord } from './bosch_shc.types.js';
import { formatMac } from './bosch_shc.mapper.js';
const boschHttpType = '_http._tcp.local';
export class BoschShcMdnsMatcher implements IDiscoveryMatcher<IBoschShcMdnsRecord> {
public id = 'bosch-shc-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Bosch Smart Home Controller zeroconf records named Bosch SHC on _http._tcp.local.';
public async matches(recordArg: IBoschShcMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type);
const name = recordArg.name || '';
const isBoschShc = type === boschHttpType && name.toLowerCase().startsWith('bosch shc');
if (!isBoschShc) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Bosch SHC _http._tcp.local advertisement.' };
}
const macAddress = formatMac(extractMac(name) || stringValue(recordArg.txt?.mac) || stringValue(recordArg.txt?.id));
const id = macAddress || recordArg.name;
const host = recordArg.host || recordArg.hostname?.replace(/\.$/, '');
return {
matched: true,
confidence: 'certain',
reason: 'mDNS record matches Home Assistant Bosch SHC zeroconf criteria.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: 'bosch_shc',
id,
host,
port: recordArg.port,
name: nodeName(recordArg.hostname || recordArg.name),
manufacturer: 'Bosch',
model: 'Smart Home Controller',
macAddress,
metadata: {
mdnsName: recordArg.name,
mdnsType: recordArg.type,
txt: recordArg.txt,
bosch_shc: true,
},
},
};
}
}
export class BoschShcManualMatcher implements IDiscoveryMatcher<IBoschShcManualEntry> {
public id = 'bosch-shc-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Bosch SHC setup entries by selected integration, host, or Bosch metadata.';
public async matches(inputArg: IBoschShcManualEntry): Promise<IDiscoveryMatch> {
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
const model = inputArg.model?.toLowerCase() || '';
const matched = Boolean(inputArg.host || manufacturer === 'bosch' && model.includes('smart home') || inputArg.metadata?.bosch_shc);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Bosch SHC setup hints.' };
}
const id = formatMac(inputArg.macAddress || inputArg.id) || inputArg.id;
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Bosch SHC host setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: 'bosch_shc',
id,
host: inputArg.host,
port: inputArg.port,
name: inputArg.name,
manufacturer: 'Bosch',
model: inputArg.model || 'Smart Home Controller',
macAddress: formatMac(inputArg.macAddress),
metadata: { ...inputArg.metadata, bosch_shc: true },
},
};
}
}
export class BoschShcCandidateValidator implements IDiscoveryValidator {
public id = 'bosch-shc-candidate-validator';
public description = 'Validate Bosch SHC candidates before starting local controller setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
const model = candidateArg.model?.toLowerCase() || '';
const matched = candidateArg.integrationDomain === 'bosch_shc'
|| Boolean(candidateArg.metadata?.bosch_shc)
|| manufacturer === 'bosch' && (model.includes('smart home') || model.includes('shc'));
const hasHost = Boolean(candidateArg.host);
return {
matched: matched && hasHost,
confidence: matched && candidateArg.id ? 'certain' : matched && hasHost ? 'high' : matched ? 'medium' : 'low',
reason: matched
? hasHost ? 'Candidate has Bosch SHC metadata and a usable local host.' : 'Candidate has Bosch SHC metadata but no local host.'
: 'Candidate is not Bosch SHC.',
candidate: matched && hasHost ? candidateArg : undefined,
normalizedDeviceId: formatMac(candidateArg.macAddress || candidateArg.id) || candidateArg.id,
};
}
}
export const createBoschShcDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({
integrationDomain: 'bosch_shc',
displayName: 'Bosch SHC',
})
.addMatcher(new BoschShcMdnsMatcher())
.addMatcher(new BoschShcManualMatcher())
.addValidator(new BoschShcCandidateValidator());
};
const normalizeMdnsType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
const extractMac = (valueArg: string): string | undefined => {
const match = /\[([^\]]+)\]/.exec(valueArg);
return match?.[1];
};
const nodeName = (valueArg?: string): string | undefined => {
const value = valueArg?.replace(/\.$/, '').replace(/\._http\._tcp\.local$/i, '').replace(/\.local$/i, '').trim();
return value || undefined;
};
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};
@@ -0,0 +1,793 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
import type {
IBoschShcBinarySensorFeature,
IBoschShcClimateFeature,
IBoschShcCoverFeature,
IBoschShcDevice,
IBoschShcDeviceService,
IBoschShcFeatureBase,
IBoschShcLightFeature,
IBoschShcModeledCommand,
IBoschShcSensorFeature,
IBoschShcSnapshot,
IBoschShcSwitchFeature,
TBoschShcCoverState,
TBoschShcFeature,
} from './bosch_shc.types.js';
const thermostatModels = new Set(['TRV', 'TRV_GEN2', 'TRV_GEN2_DUAL']);
const wallThermostatModels = new Set(['THB', 'BWTH', 'BWTH24', 'RTH2_BAT', 'RTH2_230']);
const shutterContactModels = new Set(['SWD', 'SWD2', 'SWD2_PLUS', 'SWD2_DUAL']);
const batteryModels = new Set([
'MD',
'SWD',
'SWD2',
'SWD2_PLUS',
'SWD2_DUAL',
'SD',
'SMOKE_DETECTOR2',
'TRV',
'TRV_GEN2',
'TRV_GEN2_DUAL',
'TWINGUARD',
'WRC2',
'SWITCH2',
'THB',
'BWTH',
'BWTH24',
'RTH2_BAT',
'RTH2_230',
'WLS',
]);
const smartPlugModels = new Set(['PSM', 'PLUG_COMPACT', 'PLUG_COMPACT_DUAL']);
const lightSwitchModels = new Set(['BSM', 'MICROMODULE_LIGHT_ATTACHED', 'MICROMODULE_RELAY']);
const lightModels = new Set(['LEDVANCE_LIGHT', 'HUE_LIGHT', 'MICROMODULE_DIMMER']);
const sensorMetadata: Record<string, { name: string; unit?: string; deviceClass?: string; stateClass?: string }> = {
temperature: { name: 'Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' },
humidity: { name: 'Humidity', unit: '%', deviceClass: 'humidity' },
valvetappet: { name: 'Valvetappet', unit: '%', stateClass: 'measurement' },
purity: { name: 'Purity', unit: 'ppm' },
airquality: { name: 'Air quality' },
temperature_rating: { name: 'Temperature rating' },
humidity_rating: { name: 'Humidity rating' },
purity_rating: { name: 'Purity rating' },
power: { name: 'Power', unit: 'W', deviceClass: 'power' },
energy: { name: 'Energy', unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' },
communication_quality: { name: 'Communication quality' },
};
export class BoschShcMapper {
public static toDevices(snapshotArg: IBoschShcSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const controllerId = this.controllerDeviceId(snapshotArg);
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: controllerId,
integrationDomain: 'bosch_shc',
name: snapshotArg.name || snapshotArg.information?.shcIpAddress || 'Bosch Smart Home Controller',
protocol: 'http',
manufacturer: 'Bosch',
model: 'SmartHomeController',
online: snapshotArg.online !== false,
features: [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
],
state: [
{ featureId: 'connectivity', value: snapshotArg.online === false ? 'offline' : 'online', updatedAt },
],
metadata: {
host: snapshotArg.host || snapshotArg.information?.shcIpAddress,
uniqueId: this.controllerUniqueId(snapshotArg),
macAddress: snapshotArg.information?.macAddress,
softwareVersion: snapshotArg.information?.softwareUpdateState?.swInstalledVersion,
updateState: snapshotArg.information?.softwareUpdateState?.swUpdateState,
},
}];
const featuresByDevice = new Map<string, TBoschShcFeature[]>();
for (const feature of this.features(snapshotArg)) {
const entries = featuresByDevice.get(feature.deviceId) || [];
entries.push(feature);
featuresByDevice.set(feature.deviceId, entries);
}
for (const device of snapshotArg.devices) {
if (device.deleted) continue;
const featureEntries = featuresByDevice.get(device.id) || [];
const shxFeatures: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: this.available(device) ? 'online' : 'offline', updatedAt },
];
for (const feature of featureEntries) {
if (feature.platform === 'sensor') {
shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false, unit: feature.unit });
state.push({ featureId: feature.id, value: feature.nativeValue, updatedAt });
} else if (feature.platform === 'binary_sensor') {
shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
} else if (feature.platform === 'switch') {
shxFeatures.push({ id: feature.id, capability: 'switch', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
} else if (feature.platform === 'light') {
shxFeatures.push({ id: feature.id, capability: 'light', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
if (feature.supportsBrightness) {
shxFeatures.push({ id: `${feature.id}_brightness`, capability: 'light', name: `${feature.name} brightness`, readable: true, writable: true, unit: '%' });
state.push({ featureId: `${feature.id}_brightness`, value: feature.brightness ?? null, updatedAt });
}
} else if (feature.platform === 'cover') {
shxFeatures.push({ id: feature.id, capability: 'cover', name: feature.name, readable: true, writable: true, unit: '%' });
state.push({ featureId: feature.id, value: feature.state, updatedAt });
if (feature.position !== null) {
shxFeatures.push({ id: `${feature.id}_position`, capability: 'cover', name: `${feature.name} position`, readable: true, writable: true, unit: '%' });
state.push({ featureId: `${feature.id}_position`, value: feature.position, updatedAt });
}
} else if (feature.platform === 'climate') {
shxFeatures.push({ id: feature.id, capability: 'climate', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.hvacMode, updatedAt });
if (feature.targetTemperature !== undefined) {
shxFeatures.push({ id: `${feature.id}_target_temperature`, capability: 'climate', name: `${feature.name} target temperature`, readable: true, writable: true, unit: 'C' });
state.push({ featureId: `${feature.id}_target_temperature`, value: feature.targetTemperature, updatedAt });
}
if (feature.currentTemperature !== undefined) {
shxFeatures.push({ id: `${feature.id}_current_temperature`, capability: 'climate', name: `${feature.name} current temperature`, readable: true, writable: false, unit: 'C' });
state.push({ featureId: `${feature.id}_current_temperature`, value: feature.currentTemperature, updatedAt });
}
}
}
devices.push({
id: this.deviceId(device),
integrationDomain: 'bosch_shc',
name: device.name || device.id,
protocol: 'http',
manufacturer: this.manufacturer(device),
model: device.deviceModel || 'Bosch SHC device',
online: this.available(device),
features: shxFeatures,
state,
metadata: {
rootDeviceId: device.rootDeviceId,
serial: device.serial,
profile: device.profile,
roomId: device.roomId,
status: device.status,
viaDevice: controllerId,
},
});
}
return devices;
}
public static toEntities(snapshotArg: IBoschShcSnapshot): IIntegrationEntity[] {
return this.features(snapshotArg).map((featureArg) => {
const base = {
id: this.entityId(featureArg),
uniqueId: featureArg.uniqueId,
integrationDomain: 'bosch_shc',
deviceId: this.deviceId(featureArg.device),
platform: featureArg.platform,
name: featureArg.name,
available: featureArg.available,
};
if (featureArg.platform === 'sensor') {
return {
...base,
state: featureArg.nativeValue,
attributes: {
unit: featureArg.unit,
deviceClass: featureArg.deviceClass,
stateClass: featureArg.stateClass,
...featureArg.attributes,
},
};
}
if (featureArg.platform === 'binary_sensor') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
deviceClass: featureArg.deviceClass,
...featureArg.attributes,
},
};
}
if (featureArg.platform === 'switch') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
deviceClass: featureArg.deviceClass,
entityCategory: featureArg.entityCategory,
switchKind: featureArg.kind,
},
};
}
if (featureArg.platform === 'light') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
brightness: featureArg.brightness,
colorTemperature: featureArg.colorTemperature,
minColorTemperature: featureArg.minColorTemperature,
maxColorTemperature: featureArg.maxColorTemperature,
rgb: featureArg.rgb,
supportsBrightness: featureArg.supportsBrightness,
supportsColorTemperature: featureArg.supportsColorTemperature,
supportsRgb: featureArg.supportsRgb,
},
};
}
if (featureArg.platform === 'cover') {
return {
...base,
state: featureArg.state,
attributes: {
currentPosition: featureArg.position,
deviceClass: featureArg.deviceClass,
operationState: featureArg.operationState,
calibrated: featureArg.calibrated,
supportsOpen: featureArg.supportsOpen,
supportsClose: featureArg.supportsClose,
supportsStop: featureArg.supportsStop,
supportsPosition: featureArg.supportsPosition,
},
};
}
return {
...base,
state: featureArg.hvacMode,
attributes: {
currentTemperature: featureArg.currentTemperature,
targetTemperature: featureArg.targetTemperature,
operationMode: featureArg.operationMode,
boostMode: featureArg.boostMode,
low: featureArg.low,
summerMode: featureArg.summerMode,
supportsBoostMode: featureArg.supportsBoostMode,
supportedHvacModes: featureArg.supportedHvacModes,
},
};
});
}
public static features(snapshotArg: IBoschShcSnapshot): TBoschShcFeature[] {
return [
...this.sensorFeatures(snapshotArg),
...this.binarySensorFeatures(snapshotArg),
...this.switchFeatures(snapshotArg),
...this.lightFeatures(snapshotArg),
...this.coverFeatures(snapshotArg),
...this.climateFeatures(snapshotArg),
];
}
public static commandForService(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (requestArg.domain === 'switch') {
return this.switchCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'light') {
return this.lightCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'cover') {
return this.coverCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'climate') {
return this.climateCommand(snapshotArg, requestArg);
}
return undefined;
}
public static controllerDeviceId(snapshotArg: IBoschShcSnapshot): string {
return `bosch_shc.controller.${this.slug(this.controllerUniqueId(snapshotArg) || snapshotArg.host || 'controller')}`;
}
public static deviceId(deviceArg: IBoschShcDevice): string {
return `bosch_shc.device.${this.slug(deviceArg.id || deviceArg.serial || deviceArg.name || 'device')}`;
}
public static entityId(featureArg: TBoschShcFeature): string {
const deviceSlug = this.slug(featureArg.device.name || featureArg.device.id || 'bosch_shc');
const alias = this.slug(featureArg.alias);
const objectId = this.usesDefaultEntityObjectId(featureArg) ? deviceSlug : `${deviceSlug}_${alias}`;
return `${featureArg.platform}.${objectId}`;
}
private static sensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSensorFeature[] {
const features: IBoschShcSensorFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const temperature = this.service(snapshotArg, device, 'TemperatureLevel');
if (temperature && (thermostatModels.has(model) || wallThermostatModels.has(model))) {
features.push(this.sensorFeature(device, 'temperature', this.numberState(temperature, 'temperature'), temperature));
}
const humidity = this.service(snapshotArg, device, 'HumidityLevel');
if (humidity && wallThermostatModels.has(model)) {
features.push(this.sensorFeature(device, 'humidity', this.numberState(humidity, 'humidity'), humidity));
}
const valve = this.service(snapshotArg, device, 'ValveTappet');
if (valve && thermostatModels.has(model)) {
features.push(this.sensorFeature(device, 'valvetappet', this.numberState(valve, 'position'), valve, {
valve_tappet_state: this.stringState(valve, 'value'),
}));
}
const airQuality = this.service(snapshotArg, device, 'AirQualityLevel');
if (airQuality && model === 'TWINGUARD') {
features.push(this.sensorFeature(device, 'temperature', this.numberState(airQuality, 'temperature'), airQuality));
features.push(this.sensorFeature(device, 'humidity', this.numberState(airQuality, 'humidity'), airQuality));
features.push(this.sensorFeature(device, 'purity', this.numberState(airQuality, 'purity'), airQuality));
features.push(this.sensorFeature(device, 'airquality', this.stringState(airQuality, 'combinedRating'), airQuality, {
rating_description: this.stringState(airQuality, 'description'),
}));
features.push(this.sensorFeature(device, 'temperature_rating', this.stringState(airQuality, 'temperatureRating'), airQuality));
features.push(this.sensorFeature(device, 'humidity_rating', this.stringState(airQuality, 'humidityRating'), airQuality));
features.push(this.sensorFeature(device, 'purity_rating', this.stringState(airQuality, 'purityRating'), airQuality));
}
const powerMeter = this.service(snapshotArg, device, 'PowerMeter');
if (powerMeter && (smartPlugModels.has(model) || model === 'BSM' || this.hasService(device, 'PowerSwitch'))) {
features.push(this.sensorFeature(device, 'power', this.numberState(powerMeter, 'powerConsumption'), powerMeter));
const energyWh = this.numberState(powerMeter, 'energyConsumption');
features.push(this.sensorFeature(device, 'energy', typeof energyWh === 'number' ? energyWh / 1000 : null, powerMeter));
}
const communicationQuality = this.service(snapshotArg, device, 'CommunicationQuality');
if (communicationQuality && smartPlugModels.has(model)) {
features.push(this.sensorFeature(device, 'communication_quality', this.stringState(communicationQuality, 'quality'), communicationQuality));
}
}
return features;
}
private static binarySensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcBinarySensorFeature[] {
const features: IBoschShcBinarySensorFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const shutterContact = this.service(snapshotArg, device, 'ShutterContact');
if (shutterContact && shutterContactModels.has(model)) {
const profile = device.profile || '';
features.push({
...this.featureBase(device, 'binary_sensor', 'contact', this.uniqueId(device, 'contact', true), device.name || 'Shutter contact'),
isOn: this.stringState(shutterContact, 'value') === 'OPEN',
deviceClass: profile === 'ENTRANCE_DOOR' || profile === 'FRENCH_WINDOW' ? 'door' : 'window',
attributes: { profile },
});
}
const battery = this.service(snapshotArg, device, 'BatteryLevel');
if (battery && (batteryModels.has(model) || this.hasService(device, 'BatteryLevel'))) {
const level = this.batteryWarningLevel(battery);
features.push({
...this.featureBase(device, 'binary_sensor', 'battery', this.uniqueId(device, 'battery'), `${device.name || 'Bosch SHC device'} battery`),
isOn: level !== 'OK',
deviceClass: 'battery',
attributes: { warningLevel: level },
});
}
const leakage = this.service(snapshotArg, device, 'WaterLeakageSensor');
if (leakage) {
features.push({
...this.featureBase(device, 'binary_sensor', 'water_leakage', this.uniqueId(device, 'water_leakage'), `${device.name || 'Bosch SHC device'} water leakage`),
isOn: this.stringState(leakage, 'state') === 'LEAKAGE_DETECTED',
deviceClass: 'moisture',
});
}
}
return features;
}
private static switchFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSwitchFeature[] {
const features: IBoschShcSwitchFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch');
if (powerSwitch && !lightModels.has(model)) {
const isOutlet = smartPlugModels.has(model);
features.push({
...this.featureBase(device, 'switch', 'power', this.uniqueId(device, 'power', true), device.name || 'Bosch SHC switch'),
kind: 'power_switch',
isOn: this.stringState(powerSwitch, 'switchState') === 'ON',
deviceClass: isOutlet ? 'outlet' : 'switch',
serviceId: 'PowerSwitch',
stateKey: 'switchState',
onValue: 'ON',
offValue: 'OFF',
});
}
const routing = this.service(snapshotArg, device, 'Routing');
if (routing && model === 'PSM') {
features.push({
...this.featureBase(device, 'switch', 'routing', this.uniqueId(device, 'routing'), `${device.name || 'Bosch SHC device'} routing`),
kind: 'routing',
isOn: this.stringState(routing, 'value') === 'ENABLED',
deviceClass: 'switch',
serviceId: 'Routing',
stateKey: 'value',
onValue: 'ENABLED',
offValue: 'DISABLED',
entityCategory: 'config',
});
}
const cameraLight = this.service(snapshotArg, device, 'CameraLight');
if (cameraLight && model === 'CAMERA_EYES') {
features.push({
...this.featureBase(device, 'switch', 'camera_light', this.uniqueId(device, 'camera_light'), `${device.name || 'Bosch SHC camera'} light`),
kind: 'camera_light',
isOn: this.stringState(cameraLight, 'value') === 'ON',
deviceClass: 'switch',
serviceId: 'CameraLight',
stateKey: 'value',
onValue: 'ON',
offValue: 'OFF',
});
}
const privacy = this.service(snapshotArg, device, 'PrivacyMode');
if (privacy && model === 'CAMERA_360') {
features.push({
...this.featureBase(device, 'switch', 'privacy_mode', this.uniqueId(device, 'privacy_mode'), `${device.name || 'Bosch SHC camera'} privacy mode`),
kind: 'privacy_mode',
isOn: this.stringState(privacy, 'value') === 'DISABLED',
deviceClass: 'switch',
serviceId: 'PrivacyMode',
stateKey: 'value',
onValue: 'DISABLED',
offValue: 'ENABLED',
});
}
}
return features.filter((featureArg) => lightSwitchModels.has(featureArg.device.deviceModel || '') || smartPlugModels.has(featureArg.device.deviceModel || '') || featureArg.kind !== 'power_switch');
}
private static lightFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcLightFeature[] {
const features: IBoschShcLightFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
if (!lightModels.has(model) && !this.service(snapshotArg, device, 'BinarySwitch')) continue;
const binarySwitch = this.service(snapshotArg, device, 'BinarySwitch');
const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch');
const switchService = binarySwitch || powerSwitch;
if (!switchService) continue;
const brightness = this.service(snapshotArg, device, 'MultiLevelSwitch');
const colorTemperature = this.service(snapshotArg, device, 'HueColorTemperature');
const rgb = this.service(snapshotArg, device, 'HSBColorActuator');
features.push({
...this.featureBase(device, 'light', 'light', this.uniqueId(device, 'light', true), device.name || 'Bosch SHC light'),
isOn: binarySwitch ? this.booleanState(binarySwitch, 'on') ?? null : this.stringState(switchService, 'switchState') === 'ON',
switchServiceId: binarySwitch ? 'BinarySwitch' : 'PowerSwitch',
brightness: brightness ? this.numberState(brightness, 'level') : null,
colorTemperature: colorTemperature ? this.numberState(colorTemperature, 'colorTemperature') : null,
minColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'minCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'minCt') : null,
maxColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'maxCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'maxCt') : null,
rgb: rgb ? this.numberState(rgb, 'rgb') : null,
supportsBrightness: Boolean(brightness),
supportsColorTemperature: Boolean(colorTemperature),
supportsRgb: Boolean(rgb),
});
}
return features;
}
private static coverFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcCoverFeature[] {
const features: IBoschShcCoverFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const shutter = this.service(snapshotArg, device, 'ShutterControl');
if (!shutter) continue;
const level = this.numberState(shutter, 'level');
const position = typeof level === 'number' ? Math.round(level * 100) : null;
const operationState = this.stringState(shutter, 'operationState');
const model = device.deviceModel || '';
features.push({
...this.featureBase(device, 'cover', 'cover', this.uniqueId(device, 'cover', true), device.name || 'Bosch SHC cover'),
serviceId: 'ShutterControl',
deviceClass: model === 'MICROMODULE_AWNING' ? 'awning' : model === 'MICROMODULE_BLINDS' ? 'blind' : 'shutter',
state: this.coverState(operationState, position),
position,
operationState: operationState ?? undefined,
calibrated: this.booleanState(shutter, 'calibrated'),
supportsOpen: true,
supportsClose: true,
supportsStop: true,
supportsPosition: true,
});
}
return features;
}
private static climateFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcClimateFeature[] {
const features: IBoschShcClimateFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const roomClimate = this.service(snapshotArg, device, 'RoomClimateControl');
const heatingCircuit = this.service(snapshotArg, device, 'HeatingCircuit');
const climateService = roomClimate || heatingCircuit;
if (!climateService) continue;
const temperature = this.service(snapshotArg, device, 'TemperatureLevel');
const summerMode = roomClimate ? this.booleanState(roomClimate, 'summerMode') : undefined;
features.push({
...this.featureBase(device, 'climate', 'climate', this.uniqueId(device, 'climate', true), device.name || 'Bosch SHC climate'),
serviceId: roomClimate ? 'RoomClimateControl' : 'HeatingCircuit',
hvacMode: summerMode === true ? 'off' : 'heat',
currentTemperature: temperature ? this.numberState(temperature, 'temperature') : null,
targetTemperature: this.numberState(climateService, 'setpointTemperature'),
operationMode: this.stringState(climateService, 'operationMode') ?? undefined,
boostMode: roomClimate ? this.booleanState(roomClimate, 'boostMode') : undefined,
low: roomClimate ? this.booleanState(roomClimate, 'low') : undefined,
summerMode,
supportsBoostMode: roomClimate ? this.booleanState(roomClimate, 'supportsBoostMode') : undefined,
supportedHvacModes: ['heat', 'off'],
});
}
return features;
}
private static switchCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (!['turn_on', 'turn_off'].includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC switch service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'switch') as IBoschShcSwitchFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC switch service calls require a switch entity target, or an unambiguous switch device target.' };
}
const service = this.service(snapshotArg, feature.device, feature.serviceId);
if (!service) return { error: `Bosch SHC switch service ${feature.serviceId} is missing from snapshot.` };
const value = requestArg.service === 'turn_on' ? feature.onValue : feature.offValue;
return this.putServiceState(feature.device, service, { [feature.stateKey]: value }, requestArg);
}
private static lightCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (!['turn_on', 'turn_off'].includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC light service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'light') as IBoschShcLightFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC light service calls require a light entity target, or an unambiguous light device target.' };
}
const switchService = this.service(snapshotArg, feature.device, feature.switchServiceId);
if (!switchService) return { error: `Bosch SHC light service ${feature.switchServiceId} is missing from snapshot.` };
const patch: Record<string, unknown> = feature.switchServiceId === 'BinarySwitch'
? { on: requestArg.service === 'turn_on' }
: { switchState: requestArg.service === 'turn_on' ? 'ON' : 'OFF' };
if (requestArg.service === 'turn_on') {
const brightness = requestArg.data?.brightness;
const brightnessPct = requestArg.data?.brightness_pct;
if (brightness !== undefined && !this.isByte(brightness)) {
return { error: 'Bosch SHC light brightness must be an integer between 0 and 255.' };
}
if (brightnessPct !== undefined && !this.isPercent(brightnessPct)) {
return { error: 'Bosch SHC light brightness_pct must be an integer between 0 and 100.' };
}
if (brightness !== undefined || brightnessPct !== undefined) {
const brightnessService = this.service(snapshotArg, feature.device, 'MultiLevelSwitch');
if (!brightnessService) return { error: 'Bosch SHC light brightness is not supported by this device.' };
const level = typeof brightnessPct === 'number' ? brightnessPct : Math.round((brightness as number) * 100 / 255);
return this.putServiceState(feature.device, brightnessService, { level }, requestArg);
}
}
return this.putServiceState(feature.device, switchService, patch, requestArg);
}
private static coverCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
const supported = ['open_cover', 'close_cover', 'stop_cover', 'set_cover_position'];
if (!supported.includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC cover service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'cover') as IBoschShcCoverFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC cover service calls require a cover entity target, or an unambiguous cover device target.' };
}
const service = this.service(snapshotArg, feature.device, 'ShutterControl');
if (!service) return { error: 'Bosch SHC ShutterControl service is missing from snapshot.' };
if (requestArg.service === 'open_cover') return this.putServiceState(feature.device, service, { level: 1.0 }, requestArg);
if (requestArg.service === 'close_cover') return this.putServiceState(feature.device, service, { level: 0.0 }, requestArg);
if (requestArg.service === 'stop_cover') return this.putServiceState(feature.device, service, { operationState: 'STOPPED' }, requestArg);
const position = requestArg.data?.position;
if (!this.isPercent(position)) {
return { error: 'Bosch SHC set_cover_position requires data.position as an integer between 0 and 100.' };
}
return this.putServiceState(feature.device, service, { level: position / 100 }, requestArg);
}
private static climateCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (requestArg.service !== 'set_temperature') {
return { error: `Unsupported Bosch SHC climate service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'climate') as IBoschShcClimateFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC climate service calls require a climate entity target, or an unambiguous climate device target.' };
}
const temperature = requestArg.data?.temperature;
if (typeof temperature !== 'number' || !Number.isFinite(temperature)) {
return { error: 'Bosch SHC set_temperature requires data.temperature as a finite number.' };
}
const service = this.service(snapshotArg, feature.device, feature.serviceId);
if (!service) return { error: `Bosch SHC climate service ${feature.serviceId} is missing from snapshot.` };
return this.putServiceState(feature.device, service, { setpointTemperature: temperature }, requestArg);
}
private static resolveFeature(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest, platformArg: TBoschShcFeature['platform']): TBoschShcFeature | undefined {
const features = this.features(snapshotArg).filter((featureArg) => featureArg.platform === platformArg);
if (requestArg.target.entityId) {
const entities = this.toEntities(snapshotArg);
const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId);
return features.find((featureArg) => featureArg.uniqueId === entity?.uniqueId);
}
if (requestArg.target.deviceId) {
const deviceFeatures = features.filter((featureArg) => this.deviceId(featureArg.device) === requestArg.target.deviceId);
return deviceFeatures.length === 1 ? deviceFeatures[0] : undefined;
}
return undefined;
}
private static putServiceState(deviceArg: IBoschShcDevice, serviceArg: IBoschShcDeviceService, patchArg: Record<string, unknown>, requestArg: IServiceCallRequest): IBoschShcModeledCommand {
const stateType = typeof serviceArg.state?.['@type'] === 'string' ? serviceArg.state['@type'] : undefined;
return {
action: 'put_device_service_state',
method: 'PUT',
path: `/devices/${this.encodeDeviceId(deviceArg.id)}/services/${serviceArg.id}/state`,
body: stateType ? { '@type': stateType, ...patchArg } : patchArg,
deviceId: deviceArg.id,
serviceId: serviceArg.id,
domain: requestArg.domain,
service: requestArg.service,
};
}
private static sensorFeature(deviceArg: IBoschShcDevice, keyArg: string, valueArg: string | number | null, serviceArg: IBoschShcDeviceService, attributesArg: Record<string, unknown> = {}): IBoschShcSensorFeature {
const metadata = sensorMetadata[keyArg] || { name: this.humanize(keyArg) };
void serviceArg;
return {
...this.featureBase(deviceArg, 'sensor', keyArg, this.uniqueId(deviceArg, keyArg), `${deviceArg.name || 'Bosch SHC device'} ${metadata.name.toLowerCase()}`),
nativeValue: valueArg,
unit: metadata.unit,
deviceClass: metadata.deviceClass,
stateClass: metadata.stateClass,
attributes: attributesArg,
};
}
private static featureBase<TPlatform extends TBoschShcFeature['platform']>(deviceArg: IBoschShcDevice, platformArg: TPlatform, aliasArg: string, uniqueIdArg: string, nameArg: string): IBoschShcFeatureBase & { platform: TPlatform } {
return {
platform: platformArg,
id: `${platformArg}_${this.slug(aliasArg)}`,
alias: aliasArg,
uniqueId: uniqueIdArg,
name: nameArg,
deviceId: deviceArg.id,
device: deviceArg,
available: this.available(deviceArg),
};
}
private static service(snapshotArg: IBoschShcSnapshot, deviceArg: IBoschShcDevice, serviceIdArg: string): IBoschShcDeviceService | undefined {
return deviceArg.services?.find((serviceArg) => serviceArg.id === serviceIdArg)
|| snapshotArg.services?.find((serviceArg) => serviceArg.deviceId === deviceArg.id && serviceArg.id === serviceIdArg);
}
private static activeDevices(snapshotArg: IBoschShcSnapshot): IBoschShcDevice[] {
return snapshotArg.devices.filter((deviceArg) => !deviceArg.deleted);
}
private static hasService(deviceArg: IBoschShcDevice, serviceIdArg: string): boolean {
return Boolean(deviceArg.deviceServiceIds?.includes(serviceIdArg) || deviceArg.services?.some((serviceArg) => serviceArg.id === serviceIdArg));
}
private static stringState(serviceArg: IBoschShcDeviceService, keyArg: string): string | null {
const value = serviceArg.state?.[keyArg];
return typeof value === 'string' ? value : null;
}
private static numberState(serviceArg: IBoschShcDeviceService, keyArg: string): number | null {
const value = serviceArg.state?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
return null;
}
private static numberNestedState(serviceArg: IBoschShcDeviceService, objectKeyArg: string, keyArg: string): number | null {
const record = asRecord(serviceArg.state?.[objectKeyArg]);
const value = record?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
private static booleanState(serviceArg: IBoschShcDeviceService, keyArg: string): boolean | undefined {
const value = serviceArg.state?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private static batteryWarningLevel(serviceArg: IBoschShcDeviceService): string {
const entry = serviceArg.faults?.entries?.[0];
return typeof entry?.type === 'string' ? entry.type : 'OK';
}
private static coverState(operationStateArg: string | null, positionArg: number | null): TBoschShcCoverState {
if (operationStateArg === 'OPENING') return 'opening';
if (operationStateArg === 'CLOSING') return 'closing';
if (positionArg === 0) return 'closed';
if (typeof positionArg === 'number' && positionArg > 0) return 'open';
if (operationStateArg === 'STOPPED') return 'stopped';
return 'unknown';
}
private static available(deviceArg: IBoschShcDevice): boolean {
return deviceArg.status === undefined || deviceArg.status === 'AVAILABLE';
}
private static manufacturer(deviceArg: IBoschShcDevice): string {
const value = deviceArg.manufacturer || 'Bosch';
return value.toUpperCase() === 'BOSCH' ? 'Bosch' : value;
}
private static uniqueId(deviceArg: IBoschShcDevice, aliasArg: string, defaultArg = false): string {
const base = deviceArg.serial || deviceArg.id;
return defaultArg ? base : `${base}_${aliasArg}`;
}
private static usesDefaultEntityObjectId(featureArg: TBoschShcFeature): boolean {
return featureArg.platform === 'binary_sensor' && featureArg.alias === 'contact'
|| featureArg.platform === 'switch' && featureArg.alias === 'power'
|| featureArg.platform === 'light' && featureArg.alias === 'light'
|| featureArg.platform === 'cover' && featureArg.alias === 'cover'
|| featureArg.platform === 'climate' && featureArg.alias === 'climate';
}
private static controllerUniqueId(snapshotArg: IBoschShcSnapshot): string | undefined {
return formatMac(snapshotArg.uniqueId || snapshotArg.information?.macAddress || '') || snapshotArg.host;
}
private static encodeDeviceId(valueArg: string): string {
return valueArg.replace(/#/g, '%23');
}
private static isByte(valueArg: unknown): valueArg is number {
return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 255;
}
private static isPercent(valueArg: unknown): valueArg is number {
return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 100;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bosch_shc';
}
private static humanize(valueArg: string): string {
return valueArg.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
}
}
export const formatMac = (valueArg?: string): string | undefined => {
if (!valueArg) return undefined;
const trimmed = valueArg.trim();
if (/^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/i.test(trimmed)) return trimmed.toLowerCase();
const compact = trimmed.replace(/[:.-]/g, '').toLowerCase();
if (!/^[0-9a-f]{12}$/.test(compact)) return undefined;
return compact.match(/../g)?.join('-');
};
const asRecord = (valueArg: unknown): Record<string, unknown> | undefined => {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
? valueArg as Record<string, unknown>
: undefined;
};
+269 -2
View File
@@ -1,4 +1,271 @@
export interface IHomeAssistantBoschShcConfig { export type TBoschShcSnapshotSource = 'snapshot' | 'executor';
// TODO: replace with the TypeScript-native config for bosch_shc.
export type TBoschShcEntityPlatform =
| 'sensor'
| 'binary_sensor'
| 'switch'
| 'light'
| 'climate'
| 'cover';
export type TBoschShcCommandAction =
| 'read_snapshot'
| 'get_public_information'
| 'put_device_service_state'
| 'post_domain_action'
| 'pair_client';
export type TBoschShcCommandMethod = 'GET' | 'PUT' | 'POST';
export type TBoschShcJsonValue =
| string
| number
| boolean
| null
| TBoschShcJsonValue[]
| { [key: string]: TBoschShcJsonValue | undefined };
export interface IBoschShcSoftwareUpdateState {
swInstalledVersion?: string;
swUpdateState?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface IBoschShcPublicInformation {
'@type'?: string;
shcIpAddress?: string;
macAddress?: string;
softwareUpdateState?: IBoschShcSoftwareUpdateState;
[key: string]: unknown;
}
export interface IBoschShcDevice {
'@type'?: 'device' | string;
rootDeviceId?: string;
id: string;
deviceServiceIds?: string[];
manufacturer?: string;
roomId?: string;
deviceModel?: string;
serial?: string;
profile?: string;
name?: string;
status?: string;
parentDeviceId?: string;
childDeviceIds?: string[];
deleted?: boolean;
services?: IBoschShcDeviceService[];
[key: string]: unknown;
}
export interface IBoschShcDeviceService {
'@type'?: 'DeviceServiceData' | string;
id: string;
deviceId: string;
path?: string;
state?: Record<string, unknown>;
faults?: {
entries?: Array<{
type?: string;
[key: string]: unknown;
}>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface IBoschShcRoom {
id: string;
name?: string;
iconId?: string;
[key: string]: unknown;
}
export interface IBoschShcIntrusionState {
'@type'?: 'systemState' | string;
systemAvailability?: { available?: boolean; [key: string]: unknown };
armingState?: { state?: string; remainingTimeUntilArmed?: number; [key: string]: unknown };
alarmState?: { value?: string; incidents?: unknown[]; [key: string]: unknown };
activeConfigurationProfile?: { profileId?: string | number; [key: string]: unknown };
securityGapState?: { securityGaps?: unknown[]; [key: string]: unknown };
[key: string]: unknown;
}
export interface IBoschShcSnapshot {
information?: IBoschShcPublicInformation;
authenticatedInformation?: Record<string, unknown>;
devices: IBoschShcDevice[];
services?: IBoschShcDeviceService[];
rooms?: IBoschShcRoom[];
intrusionDetection?: IBoschShcIntrusionState;
host?: string;
name?: string;
uniqueId?: string;
online?: boolean;
source?: TBoschShcSnapshotSource;
updatedAt?: string;
}
export interface IBoschShcFeatureBase {
platform: TBoschShcEntityPlatform;
id: string;
alias: string;
uniqueId: string;
name: string;
deviceId: string;
device: IBoschShcDevice;
available: boolean;
}
export interface IBoschShcSensorFeature extends IBoschShcFeatureBase {
platform: 'sensor';
nativeValue: string | number | null;
unit?: string;
deviceClass?: string;
stateClass?: string;
attributes?: Record<string, unknown>;
}
export interface IBoschShcBinarySensorFeature extends IBoschShcFeatureBase {
platform: 'binary_sensor';
isOn: boolean;
deviceClass?: 'battery' | 'door' | 'window' | 'moisture' | string;
attributes?: Record<string, unknown>;
}
export type TBoschShcSwitchKind = 'power_switch' | 'routing' | 'camera_light' | 'privacy_mode';
export interface IBoschShcSwitchFeature extends IBoschShcFeatureBase {
platform: 'switch';
kind: TBoschShcSwitchKind;
isOn: boolean | null;
deviceClass?: 'outlet' | 'switch' | string;
serviceId: string;
stateKey: string;
onValue: unknown;
offValue: unknown;
entityCategory?: 'config';
}
export interface IBoschShcLightFeature extends IBoschShcFeatureBase {
platform: 'light';
isOn: boolean | null;
switchServiceId: 'BinarySwitch' | 'PowerSwitch';
brightness?: number | null;
colorTemperature?: number | null;
minColorTemperature?: number | null;
maxColorTemperature?: number | null;
rgb?: number | null;
supportsBrightness: boolean;
supportsColorTemperature: boolean;
supportsRgb: boolean;
}
export type TBoschShcCoverState = 'opening' | 'closing' | 'open' | 'closed' | 'stopped' | 'unknown';
export interface IBoschShcCoverFeature extends IBoschShcFeatureBase {
platform: 'cover';
serviceId: 'ShutterControl';
deviceClass: 'shutter' | 'awning' | 'blind';
state: TBoschShcCoverState;
position: number | null;
operationState?: string;
calibrated?: boolean;
supportsOpen: boolean;
supportsClose: boolean;
supportsStop: boolean;
supportsPosition: boolean;
}
export interface IBoschShcClimateFeature extends IBoschShcFeatureBase {
platform: 'climate';
serviceId: 'RoomClimateControl' | 'HeatingCircuit';
hvacMode: 'heat' | 'off' | 'unknown';
currentTemperature?: number | null;
targetTemperature?: number | null;
operationMode?: string;
boostMode?: boolean;
low?: boolean;
summerMode?: boolean;
supportsBoostMode?: boolean;
supportedHvacModes: Array<'heat' | 'off'>;
}
export type TBoschShcFeature =
| IBoschShcSensorFeature
| IBoschShcBinarySensorFeature
| IBoschShcSwitchFeature
| IBoschShcLightFeature
| IBoschShcCoverFeature
| IBoschShcClimateFeature;
export interface IBoschShcModeledCommand {
action: TBoschShcCommandAction;
method: TBoschShcCommandMethod;
path: string;
body?: Record<string, unknown> | null;
deviceId?: string;
serviceId?: string;
domain?: string;
service?: string;
reason?: string;
sensitiveFields?: string[];
}
export interface IBoschShcCommandContext {
config: IBoschShcConfig;
snapshot?: IBoschShcSnapshot;
}
export type TBoschShcCommandExecutor =
| ((commandArg: IBoschShcModeledCommand, contextArg: IBoschShcCommandContext) => Promise<unknown> | unknown)
| {
execute(commandArg: IBoschShcModeledCommand, contextArg: IBoschShcCommandContext): Promise<unknown> | unknown;
};
export interface IBoschShcPairClientRequest {
systemPassword: string;
clientId?: string;
clientName?: string;
certificatePem?: string;
}
export interface IBoschShcConfig {
host?: string;
apiPort?: number;
publicPort?: number;
pairingPort?: number;
sslCertificate?: string;
sslKey?: string;
token?: string;
hostname?: string;
uniqueId?: string;
name?: string;
timeoutMs?: number;
snapshot?: IBoschShcSnapshot;
executor?: TBoschShcCommandExecutor;
}
export interface IBoschShcMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
hostname?: string;
txt?: Record<string, unknown>;
properties?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface IBoschShcManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantBoschShcConfig extends IBoschShcConfig {}
+4
View File
@@ -1,2 +1,6 @@
export * from './bosch_shc.classes.client.js';
export * from './bosch_shc.classes.configflow.js';
export * from './bosch_shc.classes.integration.js'; export * from './bosch_shc.classes.integration.js';
export * from './bosch_shc.discovery.js';
export * from './bosch_shc.mapper.js';
export * from './bosch_shc.types.js'; export * from './bosch_shc.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,115 @@
import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js';
import type {
IDevoloCommand,
IDevoloCommandResult,
IDevoloConfig,
IDevoloEvent,
IDevoloSnapshot,
} from './devolo_home_network.types.js';
type TDevoloEventHandler = (eventArg: IDevoloEvent) => void;
export class DevoloHomeNetworkClient {
private currentSnapshot?: IDevoloSnapshot;
private readonly eventHandlers = new Set<TDevoloEventHandler>();
constructor(private readonly config: IDevoloConfig) {}
public async getSnapshot(): Promise<IDevoloSnapshot> {
if (this.config.nativeClient?.getSnapshot) {
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.snapshotProvider) {
const snapshot = await this.config.snapshotProvider();
if (snapshot) {
this.currentSnapshot = this.normalizeSnapshot(snapshot, 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
}
if (!this.currentSnapshot) {
this.currentSnapshot = DevoloHomeNetworkMapper.toSnapshot(this.config);
}
return this.cloneSnapshot(this.currentSnapshot);
}
public onEvent(handlerArg: TDevoloEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IDevoloCommandResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() });
return { success: true, data: snapshot };
} catch (errorArg) {
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
const snapshot = DevoloHomeNetworkMapper.toSnapshot({ ...this.config, snapshot: this.currentSnapshot }, false);
this.currentSnapshot = snapshot;
this.emit({ type: 'refresh_failed', error, data: snapshot, timestamp: Date.now() });
return { success: false, error, data: snapshot };
}
}
public async sendCommand(commandArg: IDevoloCommand): Promise<IDevoloCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, action: commandArg.action, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
if (!executor) {
const result: IDevoloCommandResult = {
success: false,
error: 'devolo Home Network live device commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for LED, guest Wi-Fi, WPS, restart, pairing, identify, or firmware update actions.',
data: { command: commandArg },
};
this.emit({ type: 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
try {
const result = this.commandResult(await executor(commandArg), commandArg);
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
} catch (errorArg) {
const result: IDevoloCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, action: commandArg.action, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
}
public async destroy(): Promise<void> {
await this.config.nativeClient?.destroy?.();
this.eventHandlers.clear();
}
private normalizeSnapshot(snapshotArg: IDevoloSnapshot, sourceArg: IDevoloSnapshot['source']): IDevoloSnapshot {
const normalized = DevoloHomeNetworkMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
return { ...normalized, source: snapshotArg.source || sourceArg };
}
private commandResult(resultArg: unknown, commandArg: IDevoloCommand): IDevoloCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IDevoloCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private emit(eventArg: IDevoloEvent): void {
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot<TSnapshot extends IDevoloSnapshot>(snapshotArg: TSnapshot): TSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as TSnapshot;
}
}
export const DevoloClient = DevoloHomeNetworkClient;
@@ -0,0 +1,115 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IDevoloConfig, IDevoloSnapshot } from './devolo_home_network.types.js';
import { devoloHomeNetworkDefaultPort } from './devolo_home_network.types.js';
export class DevoloHomeNetworkConfigFlow implements IConfigFlow<IDevoloConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDevoloConfig>> {
void contextArg;
if (candidateArg.metadata?.homeControl === true) {
return {
kind: 'error',
title: 'Unsupported devolo Home Control device',
error: 'The devolo Home Control Central Unit is not supported by the devolo Home Network integration.',
};
}
const metadata = candidateArg.metadata || {};
const host = candidateArg.host || this.stringValue(metadata.host) || '';
const product = candidateArg.model || this.stringValue(metadata.product) || this.stringValue(metadata.Product) || '';
const serialNumber = candidateArg.serialNumber || candidateArg.id || this.stringValue(metadata.serialNumber) || this.stringValue(metadata.SN) || '';
return {
kind: 'form',
title: 'Connect devolo Home Network device',
description: 'Provide a local device host. Password is only used when a live native client/executor is supplied; snapshot/manual setup remains read-only until an executor is configured.',
fields: [
{ name: 'host', label: host ? `IP address (${host})` : 'IP address', type: 'text', required: true },
{ name: 'port', label: `Port (${candidateArg.port || devoloHomeNetworkDefaultPort})`, type: 'number' },
{ name: 'password', label: 'Device password', type: 'password' },
{ name: 'name', label: candidateArg.name ? `Name (${candidateArg.name})` : 'Name', type: 'text' },
{ name: 'product', label: product ? `Product (${product})` : 'Product', type: 'text' },
{ name: 'serialNumber', label: serialNumber ? `Serial number (${serialNumber})` : 'Serial number', type: 'text' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDevoloConfig>> {
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson ?? candidateArg.metadata?.snapshot);
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid snapshot', error: snapshot.message };
}
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.device.host || snapshot?.device.ipAddress;
if (!host && !snapshot) {
return { kind: 'error', title: 'IP address required', error: 'devolo Home Network setup requires a host or a snapshot.' };
}
const metadata = candidateArg.metadata || {};
const config: IDevoloConfig = {
host,
ipAddress: host,
port: this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.device.port || devoloHomeNetworkDefaultPort,
password: this.stringValue(valuesArg.password),
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name,
product: this.stringValue(valuesArg.product) || candidateArg.model || this.stringValue(metadata.product) || this.stringValue(metadata.Product) || snapshot?.device.product,
model: candidateArg.model || this.stringValue(valuesArg.product) || snapshot?.device.model,
serialNumber: this.stringValue(valuesArg.serialNumber) || candidateArg.serialNumber || candidateArg.id || this.stringValue(metadata.SN) || snapshot?.device.serialNumber,
macAddress: candidateArg.macAddress || snapshot?.device.macAddress,
mtNumber: this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber) || snapshot?.device.mtNumber,
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: metadata,
liveHttpImplemented: false,
},
};
return {
kind: 'done',
title: 'devolo Home Network device configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IDevoloSnapshot | undefined | Error {
if (this.isRecord(valueArg)) {
return valueArg as unknown as IDevoloSnapshot;
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as IDevoloSnapshot;
if (!parsed || !parsed.device || !Array.isArray(parsed.plcDevices) || !Array.isArray(parsed.wifiStations)) {
return new Error('Snapshot JSON must include device, plcDevices, and wifiStations fields.');
}
return parsed;
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
export const DevoloConfigFlow = DevoloHomeNetworkConfigFlow;
@@ -1,30 +1,73 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { DevoloHomeNetworkClient } from './devolo_home_network.classes.client.js';
import { DevoloHomeNetworkConfigFlow } from './devolo_home_network.classes.configflow.js';
import { createDevoloHomeNetworkDiscoveryDescriptor } from './devolo_home_network.discovery.js';
import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js';
import type { IDevoloConfig } from './devolo_home_network.types.js';
import { devoloHomeNetworkDomain } from './devolo_home_network.types.js';
export class HomeAssistantDevoloHomeNetworkIntegration extends DescriptorOnlyIntegration { export class DevoloHomeNetworkIntegration extends BaseIntegration<IDevoloConfig> {
constructor() { public readonly domain = devoloHomeNetworkDomain;
super({ public readonly displayName = 'devolo Home Network';
domain: "devolo_home_network", public readonly status = 'control-runtime' as const;
displayName: "devolo Home Network", public readonly discoveryDescriptor = createDevoloHomeNetworkDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new DevoloHomeNetworkConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/devolo_home_network", upstreamPath: 'homeassistant/components/devolo_home_network',
"upstreamDomain": "devolo_home_network", upstreamDomain: devoloHomeNetworkDomain,
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"qualityScale": "silver", qualityScale: 'silver',
"requirements": [ requirements: ['devolo-plc-api==1.5.1'],
"devolo-plc-api==1.5.1" dependencies: ['zeroconf'],
], afterDependencies: [] as string[],
"dependencies": [ codeowners: ['@2Fake', '@Shutgun'],
"zeroconf" documentation: 'https://www.home-assistant.io/integrations/devolo_home_network',
], mdnsType: '_dvl-deviceapi._tcp.local.',
"afterDependencies": [], liveHttpImplemented: false,
"codeowners": [ };
"@2Fake",
"@Shutgun" public async setup(configArg: IDevoloConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
] void contextArg;
}, return new DevoloHomeNetworkRuntime(new DevoloHomeNetworkClient(configArg));
}); }
public async destroy(): Promise<void> {}
}
export class HomeAssistantDevoloHomeNetworkIntegration extends DevoloHomeNetworkIntegration {}
class DevoloHomeNetworkRuntime implements IIntegrationRuntime {
public domain = devoloHomeNetworkDomain;
constructor(private readonly client: DevoloHomeNetworkClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DevoloHomeNetworkMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DevoloHomeNetworkMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(DevoloHomeNetworkMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const command = DevoloHomeNetworkMapper.commandForService(await this.client.getSnapshot(), requestArg);
if (!command) {
return { success: false, error: `Unsupported devolo Home Network service mapping: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
} }
} }
@@ -0,0 +1,189 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { DevoloHomeNetworkMapper } from './devolo_home_network.mapper.js';
import type { IDevoloManualDiscoveryRecord, IDevoloMdnsRecord } from './devolo_home_network.types.js';
import { devoloHomeNetworkDefaultPort, devoloHomeNetworkDomain, devoloHomeNetworkMdnsType } from './devolo_home_network.types.js';
const homeControlMtNumbers = new Set(['2600', '2601']);
const devoloTextHints = ['devolo', 'dlan', 'magic', 'home network', 'wifi repeater', 'powerline'];
export class DevoloHomeNetworkMdnsMatcher implements IDiscoveryMatcher<IDevoloMdnsRecord> {
public id = 'devolo-home-network-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize devolo Home Network mDNS device API advertisements.';
public async matches(recordArg: IDevoloMdnsRecord, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const txt = recordArg.txt || recordArg.properties || {};
const type = (recordArg.type || recordArg.serviceType || '').toLowerCase();
const product = this.txt(txt, 'Product') || this.txt(txt, 'product') || this.txt(txt, 'model');
const mtNumber = this.txt(txt, 'MT') || this.txt(txt, 'mt');
const serialNumber = this.txt(txt, 'SN') || this.txt(txt, 'sn');
const host = recordArg.host || recordArg.addresses?.[0];
const hostname = recordArg.hostname || recordArg.name;
const text = [type, recordArg.name, hostname, product]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const serviceMatched = type === devoloHomeNetworkMdnsType;
const textMatched = devoloTextHints.some((hintArg) => text.includes(hintArg));
if (homeControlMtNumbers.has(mtNumber || '')) {
return {
matched: false,
confidence: 'high',
reason: 'The devolo Home Control Central Unit is explicitly unsupported by the Home Assistant integration.',
metadata: { homeControl: true, mtNumber },
};
}
if (!serviceMatched && !textMatched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a devolo Home Network device API advertisement.' };
}
const name = this.shortHostname(hostname) || product || 'devolo Home Network';
return {
matched: true,
confidence: serviceMatched && serialNumber ? 'certain' : serviceMatched && host ? 'high' : 'medium',
reason: serviceMatched ? 'mDNS record advertises the devolo device API service.' : 'mDNS record contains devolo Home Network metadata.',
normalizedDeviceId: serialNumber || host || recordArg.name,
candidate: {
source: 'mdns',
integrationDomain: devoloHomeNetworkDomain,
id: serialNumber || recordArg.name || host,
host,
port: recordArg.port || devoloHomeNetworkDefaultPort,
name,
manufacturer: 'devolo',
model: product || mtNumber || 'devolo Home Network device',
serialNumber,
metadata: {
mdnsName: recordArg.name,
mdnsType: recordArg.type || recordArg.serviceType,
txt,
Product: product,
SN: serialNumber,
MT: mtNumber,
},
},
metadata: { product, serialNumber, mtNumber },
};
}
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()] || txtArg[keyArg.toLowerCase()];
}
private shortHostname(valueArg?: string): string | undefined {
return valueArg?.split('.')[0];
}
}
export class DevoloHomeNetworkManualMatcher implements IDiscoveryMatcher<IDevoloManualDiscoveryRecord> {
public id = 'devolo-home-network-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual devolo Home Network setup entries, including snapshot-only records.';
public async matches(inputArg: IDevoloManualDiscoveryRecord, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const host = inputArg.host || inputArg.ipAddress;
const manufacturer = inputArg.manufacturer || inputArg.brand;
const product = inputArg.product || inputArg.model;
const text = [inputArg.integrationDomain, manufacturer, product, inputArg.name, inputArg.hostname, metadata.product, metadata.model]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const mtNumber = inputArg.mtNumber || this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber);
const snapshot = inputArg.snapshot || metadata.snapshot;
const matched = inputArg.integrationDomain === devoloHomeNetworkDomain
|| metadata.devolo === true
|| Boolean(snapshot)
|| Boolean(host && (!text || devoloTextHints.some((hintArg) => text.includes(hintArg)) || product || inputArg.serialNumber));
if (homeControlMtNumbers.has(mtNumber || '')) {
return { matched: false, confidence: 'high', reason: 'Manual entry is a devolo Home Control Central Unit, which is unsupported.', metadata: { homeControl: true, mtNumber } };
}
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain devolo Home Network setup data.' };
}
const id = inputArg.id || inputArg.serialNumber || inputArg.macAddress || host || `snapshot-${Date.now()}`;
return {
matched: true,
confidence: snapshot ? 'certain' : host && (inputArg.serialNumber || product) ? 'high' : host ? 'medium' : 'low',
reason: snapshot ? 'Manual entry includes a devolo Home Network snapshot.' : 'Manual entry can start devolo Home Network setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: devoloHomeNetworkDomain,
id,
host,
port: inputArg.port || devoloHomeNetworkDefaultPort,
name: inputArg.name || inputArg.hostname || product || 'devolo Home Network',
manufacturer: 'devolo',
model: product || 'devolo Home Network device',
serialNumber: inputArg.serialNumber,
macAddress: DevoloHomeNetworkMapper.normalizeMac(inputArg.macAddress),
metadata: {
...metadata,
devolo: true,
manual: true,
product,
mtNumber,
snapshot,
},
},
metadata: { snapshotConfigured: Boolean(snapshot), mtNumber },
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
}
export class DevoloHomeNetworkCandidateValidator implements IDiscoveryValidator {
public id = 'devolo-home-network-candidate-validator';
public description = 'Validate devolo Home Network discovery candidates.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = candidateArg.metadata || {};
const mtNumber = this.stringValue(metadata.MT) || this.stringValue(metadata.mtNumber);
if (homeControlMtNumbers.has(mtNumber || '') || metadata.homeControl === true) {
return { matched: false, confidence: 'high', reason: 'The candidate is a devolo Home Control Central Unit, which is unsupported.', metadata: { homeControl: true, mtNumber } };
}
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.product, metadata.Product]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = candidateArg.integrationDomain === devoloHomeNetworkDomain
|| metadata.devolo === true
|| candidateArg.manufacturer?.toLowerCase() === 'devolo'
|| devoloTextHints.some((hintArg) => text.includes(hintArg))
|| metadata.snapshot !== undefined;
return {
matched,
confidence: matched && candidateArg.host && (candidateArg.serialNumber || metadata.SN) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has devolo Home Network metadata.' : 'Candidate is not a devolo Home Network device.',
candidate: matched ? { ...candidateArg, port: candidateArg.port || devoloHomeNetworkDefaultPort } : undefined,
normalizedDeviceId: candidateArg.serialNumber || this.stringValue(metadata.SN) || candidateArg.id || candidateArg.macAddress || candidateArg.host,
metadata: matched ? { mtNumber, liveHttpImplemented: false } : undefined,
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
}
export const createDevoloHomeNetworkDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: devoloHomeNetworkDomain, displayName: 'devolo Home Network' })
.addMatcher(new DevoloHomeNetworkMdnsMatcher())
.addMatcher(new DevoloHomeNetworkManualMatcher())
.addValidator(new DevoloHomeNetworkCandidateValidator());
};
export const createDevoloDiscoveryDescriptor = createDevoloHomeNetworkDiscoveryDescriptor;
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,326 @@
export interface IHomeAssistantDevoloHomeNetworkConfig { import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for devolo_home_network.
export const devoloHomeNetworkDomain = 'devolo_home_network';
export const devoloHomeNetworkDefaultPort = 80;
export const devoloHomeNetworkMdnsType = '_dvl-deviceapi._tcp.local.';
export type TDevoloSnapshotSource = 'manual' | 'snapshot' | 'provider' | 'runtime';
export type TDevoloFeature = 'led' | 'restart' | 'update' | 'wifi1' | string;
export type TDevoloActionName =
| 'identify'
| 'pairing'
| 'restart'
| 'start_wps'
| 'set_leds'
| 'set_guest_wifi'
| 'install_firmware';
export type TDevoloCommandType =
| 'plc.identify'
| 'plc.pair_device'
| 'device.restart'
| 'wifi.start_wps'
| 'device.set_leds'
| 'wifi.set_guest_access'
| 'firmware.install';
export interface IDevoloDeviceInfo {
id?: string;
host?: string;
ipAddress?: string;
port?: number;
name?: string;
hostname?: string;
product?: string;
model?: string;
mtNumber?: string;
serialNumber?: string;
macAddress?: string;
firmwareVersion?: string;
features?: TDevoloFeature[];
deviceApi?: boolean;
plcnetApi?: boolean;
configurationUrl?: string;
metadata?: Record<string, unknown>;
}
export interface IDevoloPlcDevice {
id?: string;
macAddress?: string;
mac_address?: string;
name?: string;
userDeviceName?: string;
user_device_name?: string;
product?: string;
model?: string;
serialNumber?: string;
firmwareVersion?: string;
topology?: 'LOCAL' | 'REMOTE' | 'local' | 'remote' | string;
attachedToRouter?: boolean;
attached_to_router?: boolean;
ipAddress?: string;
metadata?: Record<string, unknown>;
}
export interface IDevoloPlcLink {
id?: string;
fromMac?: string;
toMac?: string;
macAddressFrom?: string;
macAddressTo?: string;
mac_address_from?: string;
mac_address_to?: string;
rxRate?: number;
txRate?: number;
rx_rate?: number;
tx_rate?: number;
quality?: string | number;
metadata?: Record<string, unknown>;
}
export interface IDevoloWifiStation {
id?: string;
macAddress?: string;
mac_address?: string;
name?: string;
hostname?: string;
ipAddress?: string;
ip?: string;
connected?: boolean;
vapType?: string | number;
vap_type?: string | number;
band?: string | number;
ssid?: string;
rssi?: number;
signal?: number;
metadata?: Record<string, unknown>;
}
export interface IDevoloNeighborWifiNetwork {
id?: string;
ssid?: string;
bssid?: string;
macAddress?: string;
band?: string | number;
channel?: number;
rssi?: number;
signal?: number;
encryption?: string;
metadata?: Record<string, unknown>;
}
export interface IDevoloGuestWifiState {
enabled?: boolean;
ssid?: string;
key?: string;
password?: string;
band?: string | number;
duration?: number;
available?: boolean;
metadata?: Record<string, unknown>;
}
export interface IDevoloSwitchState {
enabled?: boolean;
available?: boolean;
metadata?: Record<string, unknown>;
}
export interface IDevoloSwitchMap {
switch_leds?: IDevoloSwitchState | boolean;
leds?: IDevoloSwitchState | boolean;
switch_guest_wifi?: IDevoloGuestWifiState | IDevoloSwitchState | boolean;
guestWifi?: IDevoloGuestWifiState | IDevoloSwitchState | boolean;
[key: string]: unknown; [key: string]: unknown;
} }
export interface IDevoloFirmwareUpdate {
installedVersion?: string;
currentVersion?: string;
latestVersion?: string;
newFirmwareVersion?: string;
available?: boolean;
inProgress?: boolean;
releaseUrl?: string;
metadata?: Record<string, unknown>;
}
export interface IDevoloSensorMap {
connected_plc_devices?: number;
connected_wifi_clients?: number;
neighboring_wifi_networks?: number;
plc_rx_rate?: number;
plc_tx_rate?: number;
last_restart?: string | number;
uptimeSeconds?: number;
[key: string]: unknown;
}
export interface IDevoloActionDescriptor {
action: TDevoloActionName;
type: TDevoloCommandType;
target: 'device' | 'plcnet' | 'wifi' | 'firmware';
platform?: TEntityPlatform;
entityId?: string;
deviceId?: string;
available?: boolean;
requiresPassword?: boolean;
metadata?: Record<string, unknown>;
}
export interface IDevoloEvent {
type: string;
timestamp?: number;
deviceId?: string;
entityId?: string;
action?: TDevoloActionName;
command?: IDevoloCommand;
data?: unknown;
error?: string;
[key: string]: unknown;
}
export interface IDevoloSnapshot {
connected: boolean;
source?: TDevoloSnapshotSource;
updatedAt?: string;
device: IDevoloDeviceInfo;
plcDevices: IDevoloPlcDevice[];
plcLinks: IDevoloPlcLink[];
wifiStations: IDevoloWifiStation[];
neighboringWifiNetworks: IDevoloNeighborWifiNetwork[];
firmware?: IDevoloFirmwareUpdate;
sensors: IDevoloSensorMap;
switches: {
leds?: IDevoloSwitchState;
guestWifi?: IDevoloGuestWifiState;
};
actions: IDevoloActionDescriptor[];
events: IDevoloEvent[];
error?: string;
metadata?: Record<string, unknown>;
}
export interface IDevoloManualEntry {
id?: string;
host?: string;
ipAddress?: string;
port?: number;
name?: string;
hostname?: string;
product?: string;
model?: string;
mtNumber?: string;
serialNumber?: string;
macAddress?: string;
firmwareVersion?: string;
features?: TDevoloFeature[];
device?: IDevoloDeviceInfo;
snapshot?: IDevoloSnapshot;
plcDevices?: IDevoloPlcDevice[];
devices?: IDevoloPlcDevice[];
plcLinks?: IDevoloPlcLink[];
links?: IDevoloPlcLink[];
dataRates?: IDevoloPlcLink[];
wifiStations?: IDevoloWifiStation[];
stations?: IDevoloWifiStation[];
clients?: IDevoloWifiStation[];
neighboringWifiNetworks?: IDevoloNeighborWifiNetwork[];
neighbors?: IDevoloNeighborWifiNetwork[];
firmware?: IDevoloFirmwareUpdate;
sensors?: IDevoloSensorMap;
switches?: IDevoloSwitchMap;
actions?: IDevoloActionDescriptor[];
metadata?: Record<string, unknown>;
integrationDomain?: string;
[key: string]: unknown;
}
export interface IDevoloConfig {
host?: string;
ipAddress?: string;
port?: number;
password?: string;
name?: string;
hostname?: string;
product?: string;
model?: string;
mtNumber?: string;
serialNumber?: string;
macAddress?: string;
firmwareVersion?: string;
features?: TDevoloFeature[];
connected?: boolean;
deviceApi?: boolean;
plcnetApi?: boolean;
snapshot?: IDevoloSnapshot;
snapshotProvider?: () => Promise<IDevoloSnapshot | undefined> | IDevoloSnapshot | undefined;
device?: IDevoloDeviceInfo;
manualEntries?: IDevoloManualEntry[];
plcDevices?: IDevoloPlcDevice[];
devices?: IDevoloPlcDevice[];
plcLinks?: IDevoloPlcLink[];
links?: IDevoloPlcLink[];
dataRates?: IDevoloPlcLink[];
wifiStations?: IDevoloWifiStation[];
stations?: IDevoloWifiStation[];
clients?: IDevoloWifiStation[];
neighboringWifiNetworks?: IDevoloNeighborWifiNetwork[];
neighbors?: IDevoloNeighborWifiNetwork[];
firmware?: IDevoloFirmwareUpdate;
sensors?: IDevoloSensorMap;
switches?: IDevoloSwitchMap;
events?: IDevoloEvent[];
actions?: IDevoloActionDescriptor[];
commandExecutor?: TDevoloCommandExecutor;
nativeClient?: IDevoloNativeClient;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IDevoloCommand {
type: TDevoloCommandType;
action: TDevoloActionName;
service: string;
target: {
entityId?: string;
deviceId?: string;
};
deviceId?: string;
entityId?: string;
enabled?: boolean;
requiresPassword?: boolean;
payload: Record<string, unknown>;
}
export interface IDevoloCommandResult extends IServiceCallResult {}
export type TDevoloCommandExecutor = (
commandArg: IDevoloCommand
) => Promise<IDevoloCommandResult | unknown> | IDevoloCommandResult | unknown;
export interface IDevoloNativeClient {
getSnapshot?: () => Promise<IDevoloSnapshot> | IDevoloSnapshot;
executeCommand?: TDevoloCommandExecutor;
validate?: (configArg: IDevoloConfig) => Promise<IDevoloDeviceInfo | undefined> | IDevoloDeviceInfo | undefined;
destroy?: () => Promise<void> | void;
}
export interface IDevoloMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
hostname?: string;
host?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
[key: string]: unknown;
}
export interface IDevoloManualDiscoveryRecord extends IDevoloManualEntry {
manufacturer?: string;
brand?: string;
}
export interface IHomeAssistantDevoloHomeNetworkConfig extends IDevoloConfig {}
@@ -1,2 +1,6 @@
export * from './devolo_home_network.classes.client.js';
export * from './devolo_home_network.classes.configflow.js';
export * from './devolo_home_network.classes.integration.js'; export * from './devolo_home_network.classes.integration.js';
export * from './devolo_home_network.discovery.js';
export * from './devolo_home_network.mapper.js';
export * from './devolo_home_network.types.js'; export * from './devolo_home_network.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,112 @@
import type { IFritzCommand, IFritzCommandResult, IFritzConfig, IFritzEvent, IFritzSnapshot } from './fritz.types.js';
import { FritzMapper } from './fritz.mapper.js';
type TFritzEventHandler = (eventArg: IFritzEvent) => void;
export class FritzClient {
private currentSnapshot?: IFritzSnapshot;
private readonly eventHandlers = new Set<TFritzEventHandler>();
constructor(private readonly config: IFritzConfig) {}
public async getSnapshot(): Promise<IFritzSnapshot> {
if (this.config.nativeClient) {
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.snapshotProvider) {
const provided = await this.config.snapshotProvider();
if (provided) {
this.currentSnapshot = this.normalizeSnapshot(provided, 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
}
if (!this.currentSnapshot) {
this.currentSnapshot = FritzMapper.toSnapshot(this.config);
}
return this.cloneSnapshot(this.currentSnapshot);
}
public onEvent(handlerArg: TFritzEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IFritzCommandResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
this.emit({ type: 'snapshot_refreshed', snapshot, timestamp: Date.now() });
return { success: true, data: snapshot };
} catch (errorArg) {
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
const snapshot = FritzMapper.toSnapshot({ ...this.config, connected: false, snapshot: this.currentSnapshot }, false);
this.currentSnapshot = snapshot;
this.emit({ type: 'refresh_failed', snapshot, error, timestamp: Date.now() });
return { success: false, error, data: snapshot };
}
}
public async sendCommand(commandArg: IFritzCommand): Promise<IFritzCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
if (!executor) {
const result: IFritzCommandResult = {
success: false,
error: this.unsupportedCommandMessage(commandArg),
data: { command: commandArg },
};
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
try {
const result = this.commandResult(await executor(commandArg), commandArg);
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
} catch (errorArg) {
const result: IFritzCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
}
public async destroy(): Promise<void> {
await this.config.nativeClient?.destroy?.();
this.eventHandlers.clear();
}
private normalizeSnapshot(snapshotArg: IFritzSnapshot, sourceArg: IFritzSnapshot['source']): IFritzSnapshot {
const normalized = FritzMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
return { ...normalized, source: snapshotArg.source || sourceArg };
}
private commandResult(resultArg: unknown, commandArg: IFritzCommand): IFritzCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IFritzCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private unsupportedCommandMessage(commandArg: IFritzCommand): string {
const action = commandArg.action.replace(/_/g, ' ');
return `FRITZ!Box live TR-064/HTTP ${action} commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live router, switch, client, or service actions.`;
}
private emit(eventArg: IFritzEvent): void {
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot<T extends IFritzSnapshot>(snapshotArg: T): T {
return JSON.parse(JSON.stringify(snapshotArg)) as T;
}
}
@@ -0,0 +1,120 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { FritzMapper } from './fritz.mapper.js';
import type { IFritzConfig, IFritzSnapshot } from './fritz.types.js';
import { fritzDefaultConsiderHomeSeconds, fritzDefaultHost, fritzDefaultSsl } from './fritz.types.js';
export class FritzConfigFlow implements IConfigFlow<IFritzConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IFritzConfig>> {
void contextArg;
const metadata = candidateArg.metadata || {};
const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl;
return {
kind: 'form',
title: 'Connect FRITZ!Box Tools',
description: 'Provide the local FRITZ!Box endpoint. Snapshot/manual data is supported directly; live TR-064/HTTP success is not assumed without an injected native client or command executor.',
fields: [
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : `Host (${fritzDefaultHost})`, type: 'text', required: true },
{ name: 'port', label: `Port (${candidateArg.port || FritzMapper.defaultPort(ssl)})`, type: 'number' },
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
{ name: 'ssl', label: 'Use SSL', type: 'boolean' },
{ name: 'featureDeviceTracking', label: 'Enable network device tracking', type: 'boolean' },
{ name: 'oldDiscovery', label: 'Use old hosts discovery method', type: 'boolean' },
{ name: 'considerHomeSeconds', label: `Seconds to consider a device home (${fritzDefaultConsiderHomeSeconds})`, type: 'number' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IFritzConfig>> {
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid FRITZ!Box snapshot', error: snapshot.message };
}
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(candidateArg.metadata?.ssl) ?? snapshot?.router.ssl ?? fritzDefaultSsl;
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host || (!snapshot ? fritzDefaultHost : undefined);
if (!host && !snapshot) {
return { kind: 'error', title: 'FRITZ!Box setup failed', error: 'FRITZ!Box setup requires a host or snapshot JSON.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? FritzMapper.defaultPort(ssl) : undefined);
const config: IFritzConfig = {
host,
port,
ssl,
username: this.stringValue(valuesArg.username),
password: this.stringValue(valuesArg.password),
featureDeviceTracking: this.booleanValue(valuesArg.featureDeviceTracking) ?? true,
oldDiscovery: this.booleanValue(valuesArg.oldDiscovery) ?? false,
considerHomeSeconds: this.numberValue(valuesArg.considerHomeSeconds) || fritzDefaultConsiderHomeSeconds,
uniqueId: candidateArg.id || snapshot?.router.serialNumber || snapshot?.router.macAddress,
name: candidateArg.name || snapshot?.router.name || snapshot?.router.model || host,
model: candidateArg.model || snapshot?.router.model,
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: candidateArg.metadata,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: false,
liveTr064Implemented: false,
},
};
return {
kind: 'done',
title: 'FRITZ!Box Tools configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IFritzSnapshot | undefined | Error {
if (valueArg && typeof valueArg === 'object') {
return valueArg as IFritzSnapshot;
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as IFritzSnapshot;
if (!parsed || typeof parsed !== 'object' || !parsed.router) {
return new Error('Snapshot JSON must include a router object.');
}
return parsed;
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) {
return true;
}
if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
}
}
@@ -1,32 +1,98 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { FritzClient } from './fritz.classes.client.js';
import { FritzConfigFlow } from './fritz.classes.configflow.js';
import { createFritzDiscoveryDescriptor } from './fritz.discovery.js';
import { FritzMapper } from './fritz.mapper.js';
import type { IFritzConfig } from './fritz.types.js';
import { fritzDomain } from './fritz.types.js';
export class HomeAssistantFritzIntegration extends DescriptorOnlyIntegration { export class FritzIntegration extends BaseIntegration<IFritzConfig> {
constructor() { public readonly domain = fritzDomain;
super({ public readonly displayName = 'FRITZ!Box Tools';
domain: "fritz", public readonly status = 'control-runtime' as const;
displayName: "FRITZ!Box Tools", public readonly discoveryDescriptor = createFritzDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new FritzConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/fritz", upstreamPath: 'homeassistant/components/fritz',
"upstreamDomain": "fritz", upstreamDomain: fritzDomain,
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_polling", iotClass: 'local_polling',
"qualityScale": "gold", qualityScale: 'gold',
"requirements": [ requirements: ['fritzconnection[qr]==1.15.1', 'xmltodict==1.0.4'],
"fritzconnection[qr]==1.15.1", dependencies: ['network'],
"xmltodict==1.0.4" afterDependencies: [] as string[],
], codeowners: ['@AaronDavidSchneider', '@chemelli74', '@mib1185'],
"dependencies": [ documentation: 'https://www.home-assistant.io/integrations/fritz',
"network" configFlow: true,
], runtime: {
"afterDependencies": [], mode: 'native TypeScript snapshot/manual FRITZ!Box mapping',
"codeowners": [ platforms: ['binary_sensor', 'button', 'device_tracker', 'sensor', 'switch', 'update'],
"@AaronDavidSchneider", services: ['refresh', 'snapshot', 'reboot', 'reconnect', 'firmware_update', 'cleanup', 'set_guest_wifi_password', 'dial', 'wake_on_lan'],
"@chemelli74", },
"@mib1185" localApi: {
] implemented: [
}, 'manual FRITZ!Box setup candidates and config flow',
}); 'SSDP FRITZ!Box device-type candidates matching Home Assistant manifest support',
'snapshot mapping for router connection sensors, traffic counters/rates, interfaces, client presence/device-tracker equivalents, Wi-Fi, port forwarding, call deflection, internet-access, WOL, and FRITZ!OS update controls',
'safe command modeling for explicitly represented router, client, switch, update, and service actions',
],
explicitUnsupported: [
'homeassistant_compat shims',
'fake FRITZ!Box TR-064/HTTP connection or command success without commandExecutor/nativeClient injection',
'full fritzconnection live protocol implementation in dependency-free TypeScript',
'Home Assistant image platform mapping because the current integration entity platform model has no image entity type',
],
},
};
public async setup(configArg: IFritzConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new FritzRuntime(new FritzClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantFritzIntegration extends FritzIntegration {}
class FritzRuntime implements IIntegrationRuntime {
public domain = fritzDomain;
constructor(private readonly client: FritzClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return FritzMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return FritzMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(FritzMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === fritzDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === fritzDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = FritzMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported FRITZ!Box service mapping: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
} }
} }
+297
View File
@@ -0,0 +1,297 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { FritzMapper } from './fritz.mapper.js';
import type { IFritzManualDiscoveryRecord, IFritzMdnsDiscoveryRecord, IFritzSnapshot, IFritzSsdpDiscoveryRecord } from './fritz.types.js';
import { fritzDefaultSsl, fritzDomain } from './fritz.types.js';
const fritzTextHints = ['fritz!box', 'fritzbox', 'fritz box', 'fritz!repeater', 'fritz!wlan', 'avm'];
const fritzSsdpSt = 'urn:schemas-upnp-org:device:fritzbox:1';
export class FritzManualMatcher implements IDiscoveryMatcher<IFritzManualDiscoveryRecord> {
public id = 'fritz-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual FRITZ!Box Tools setup entries, including snapshot-only records.';
public async matches(inputArg: IFritzManualDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot as IFritzSnapshot | undefined;
const host = inputArg.host || snapshot?.router.host;
const mac = FritzMapper.normalizeMac(inputArg.macAddress || inputArg.serialNumber || snapshot?.router.macAddress || snapshot?.router.serialNumber);
const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.name);
const hasSnapshot = Boolean(snapshot);
const matched = inputArg.integrationDomain === fritzDomain
|| metadata.fritz === true
|| hasSnapshot
|| fritzTextHints.some((hintArg) => text.includes(hintArg))
|| Boolean(host && !text);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain FRITZ!Box setup hints.' };
}
const ssl = this.booleanValue(inputArg.ssl) ?? this.booleanValue(metadata.ssl) ?? snapshot?.router.ssl ?? fritzDefaultSsl;
const port = inputArg.port || snapshot?.router.port || FritzMapper.defaultPort(ssl);
const id = inputArg.id || inputArg.serialNumber || snapshot?.router.serialNumber || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: hasSnapshot || mac ? 'certain' : host ? 'high' : 'medium',
reason: hasSnapshot ? 'Manual entry includes a FRITZ!Box snapshot.' : 'Manual entry can start FRITZ!Box Tools setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: fritzDomain,
id,
host,
port,
name: inputArg.name || snapshot?.router.name || snapshot?.router.model || host || 'FRITZ!Box',
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'FRITZ!',
model: inputArg.model || snapshot?.router.model || 'FRITZ!Box',
serialNumber: inputArg.serialNumber || snapshot?.router.serialNumber,
macAddress: mac,
metadata: {
...metadata,
fritz: true,
ssl,
hasSnapshot,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: false,
liveTr064Implemented: false,
},
},
metadata: { hasSnapshot, ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false },
};
}
private text(...valuesArg: unknown[]): string {
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
}
private booleanValue(valueArg: unknown): boolean | undefined {
return typeof valueArg === 'boolean' ? valueArg : undefined;
}
}
export class FritzSsdpMatcher implements IDiscoveryMatcher<IFritzSsdpDiscoveryRecord> {
public id = 'fritz-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Home Assistant supported FRITZ!Box SSDP advertisements.';
public async matches(inputArg: IFritzSsdpDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const upnp = inputArg.upnp || {};
const st = this.stringValue(inputArg.st || metadata.st || metadata.ssdpSt);
const location = this.stringValue(inputArg.ssdpLocation || inputArg.ssdp_location || inputArg.location || metadata.ssdpLocation || metadata.location);
const host = inputArg.host || this.hostFromLocation(location);
const friendlyName = this.firstString(upnp.friendlyName, upnp.FriendlyName, upnp.friendly_name, upnp['upnp:ATTR_UPNP_FRIENDLY_NAME'], upnp['friendlyName'], metadata.friendlyName, inputArg.name);
const modelName = this.firstString(upnp.modelName, upnp.ModelName, upnp.model_name, upnp['upnp:ATTR_UPNP_MODEL_NAME'], metadata.modelName, inputArg.model);
const udn = this.firstString(upnp.UDN, upnp.udn, metadata.udn, inputArg.udn, inputArg.usn);
const uuid = udn?.replace(/^uuid:/i, '').split('::')[0];
const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, friendlyName, modelName, st, metadata.manufacturer, metadata.model, metadata.name);
const matched = inputArg.integrationDomain === fritzDomain
|| metadata.fritz === true
|| st === fritzSsdpSt
|| text.includes(fritzSsdpSt)
|| fritzTextHints.some((hintArg) => text.includes(hintArg));
if (!matched || !host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'FRITZ!Box SSDP advertisement lacks a usable host.' : 'SSDP advertisement is not FRITZ!Box Tools.',
};
}
const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl;
const port = inputArg.port || this.portFromLocation(location) || FritzMapper.defaultPort(ssl);
return {
matched: true,
confidence: st === fritzSsdpSt || uuid ? 'certain' : 'high',
reason: 'SSDP advertisement matches the FRITZ!Box device type supported by Home Assistant.',
normalizedDeviceId: inputArg.id || uuid || `${host}:${port}`,
candidate: {
source: 'ssdp',
integrationDomain: fritzDomain,
id: inputArg.id || uuid || `${host}:${port}`,
host,
port,
name: inputArg.name || friendlyName || modelName || 'FRITZ!Box',
manufacturer: inputArg.manufacturer || 'FRITZ!',
model: inputArg.model || modelName || 'FRITZ!Box',
serialNumber: uuid,
metadata: {
...metadata,
fritz: true,
ssl,
ssdpSt: st,
ssdpLocation: location,
upnp,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: false,
liveTr064Implemented: false,
},
},
metadata: { ssl, ssdpSt: st, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false },
};
}
private firstString(...valuesArg: unknown[]): string | undefined {
return valuesArg.find((valueArg): valueArg is string => typeof valueArg === 'string' && Boolean(valueArg.trim()))?.trim();
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private text(...valuesArg: unknown[]): string {
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
}
private booleanValue(valueArg: unknown): boolean | undefined {
return typeof valueArg === 'boolean' ? valueArg : undefined;
}
private hostFromLocation(locationArg?: string): string | undefined {
if (!locationArg) {
return undefined;
}
try {
const host = new URL(locationArg).hostname;
return host && !host.toLowerCase().startsWith('fe80') ? host : undefined;
} catch {
return undefined;
}
}
private portFromLocation(locationArg?: string): number | undefined {
if (!locationArg) {
return undefined;
}
try {
const parsed = Number(new URL(locationArg).port);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
} catch {
return undefined;
}
}
}
export class FritzMdnsMatcher implements IDiscoveryMatcher<IFritzMdnsDiscoveryRecord> {
public id = 'fritz-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize zeroconf/mDNS FRITZ candidates when supplied by a local discovery layer.';
public async matches(inputArg: IFritzMdnsDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, inputArg.serviceType, inputArg.type, inputArg.fullname, metadata.manufacturer, metadata.model, metadata.name, metadata.serviceType);
const matched = inputArg.integrationDomain === fritzDomain
|| metadata.fritz === true
|| fritzTextHints.some((hintArg) => text.includes(hintArg));
if (!matched || !inputArg.host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'FRITZ!Box zeroconf candidate lacks a usable host.' : 'mDNS candidate is not FRITZ!Box Tools.',
};
}
const ssl = this.booleanValue(metadata.ssl) ?? fritzDefaultSsl;
const id = inputArg.id || inputArg.serialNumber || FritzMapper.normalizeMac(inputArg.macAddress) || `${inputArg.host}:${inputArg.port || FritzMapper.defaultPort(ssl)}`;
return {
matched: true,
confidence: inputArg.serialNumber || inputArg.macAddress ? 'certain' : 'high',
reason: 'mDNS/zeroconf candidate contains FRITZ!Box identity hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: fritzDomain,
id,
host: inputArg.host,
port: inputArg.port || FritzMapper.defaultPort(ssl),
name: inputArg.name || inputArg.model || 'FRITZ!Box',
manufacturer: inputArg.manufacturer || 'FRITZ!',
model: inputArg.model || 'FRITZ!Box',
serialNumber: inputArg.serialNumber,
macAddress: FritzMapper.normalizeMac(inputArg.macAddress),
metadata: {
...metadata,
fritz: true,
ssl,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: false,
liveTr064Implemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false },
};
}
private text(...valuesArg: unknown[]): string {
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
}
private booleanValue(valueArg: unknown): boolean | undefined {
return typeof valueArg === 'boolean' ? valueArg : undefined;
}
}
export class FritzCandidateValidator implements IDiscoveryValidator {
public id = 'fritz-candidate-validator';
public description = 'Validate FRITZ candidates have a host or snapshot and FRITZ!Box identity metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IFritzSnapshot | undefined;
const mac = FritzMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress || snapshot?.router.serialNumber);
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.ssdpSt]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = candidateArg.integrationDomain === fritzDomain
|| metadata.fritz === true
|| Boolean(snapshot)
|| metadata.ssdpSt === fritzSsdpSt
|| fritzTextHints.some((hintArg) => text.includes(hintArg));
const hasUsableSource = Boolean(candidateArg.host || snapshot);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'FRITZ!Box candidate lacks host or snapshot information.' : 'Candidate is not FRITZ!Box Tools.',
};
}
const ssl = typeof metadata.ssl === 'boolean' ? metadata.ssl : snapshot?.router.ssl ?? fritzDefaultSsl;
const port = candidateArg.port || snapshot?.router.port || FritzMapper.defaultPort(ssl);
const normalizedDeviceId = candidateArg.id || snapshot?.router.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id);
return {
matched: true,
confidence: mac || snapshot || metadata.ssdpSt === fritzSsdpSt ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has FRITZ!Box metadata and a usable local source.',
normalizedDeviceId,
candidate: {
...candidateArg,
id: candidateArg.id || normalizedDeviceId,
port,
macAddress: mac || candidateArg.macAddress,
metadata: {
...metadata,
ssl,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: false,
liveTr064Implemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: false, liveTr064Implemented: false },
};
}
}
export const createFritzDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: fritzDomain, displayName: 'FRITZ!Box Tools' })
.addMatcher(new FritzManualMatcher())
.addMatcher(new FritzSsdpMatcher())
.addMatcher(new FritzMdnsMatcher())
.addValidator(new FritzCandidateValidator());
};
File diff suppressed because it is too large Load Diff
+367 -2
View File
@@ -1,4 +1,369 @@
export interface IHomeAssistantFritzConfig { import type { IServiceCallResult } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for fritz.
export const fritzDomain = 'fritz';
export const fritzDefaultHost = '192.168.178.1';
export const fritzDefaultHttpPort = 49000;
export const fritzDefaultHttpsPort = 49443;
export const fritzDefaultSsl = false;
export const fritzDefaultConsiderHomeSeconds = 180;
export type TFritzProtocol = 'http' | 'https';
export type TFritzMeshRole = 'none' | 'master' | 'slave';
export type TFritzSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
export type TFritzCommandType = 'router.action' | 'client.action' | 'switch.set' | 'service.action';
export type TFritzRouterAction = 'firmware_update' | 'reboot' | 'reconnect' | 'cleanup';
export type TFritzClientAction = 'wake_on_lan' | 'set_wan_access';
export type TFritzSwitchAction = 'set_wifi_enabled' | 'set_port_forward_enabled' | 'set_call_deflection_enabled';
export type TFritzServiceAction = 'set_guest_wifi_password' | 'dial';
export type TFritzAction = TFritzRouterAction | TFritzClientAction | TFritzSwitchAction | TFritzServiceAction;
export interface IFritzConfig {
host?: string;
port?: number;
ssl?: boolean;
username?: string;
password?: string;
featureDeviceTracking?: boolean;
oldDiscovery?: boolean;
considerHomeSeconds?: number;
connected?: boolean;
uniqueId?: string;
name?: string;
model?: string;
snapshot?: IFritzSnapshot;
router?: IFritzRouterInfo;
devices?: IFritzClientDevice[];
clients?: IFritzClientDevice[];
interfaces?: IFritzInterfaceStats[];
connection?: IFritzConnectionInfo;
sensors?: IFritzSensorMap;
wifiNetworks?: IFritzWifiNetwork[];
portForwards?: IFritzPortForward[];
callDeflections?: IFritzCallDeflection[];
update?: IFritzUpdateInfo;
actions?: IFritzActionDescriptor[];
manualEntries?: IFritzManualEntry[];
events?: IFritzEvent[];
snapshotProvider?: TFritzSnapshotProvider;
commandExecutor?: TFritzCommandExecutor;
nativeClient?: IFritzNativeClient;
metadata?: Record<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
export interface IHomeAssistantFritzConfig extends IFritzConfig {}
export interface IFritzRouterInfo {
id?: string;
host?: string;
port?: number;
ssl?: boolean;
name?: string;
model?: string;
serialNumber?: string;
firmware?: string;
currentFirmware?: string;
latestFirmware?: string;
updateAvailable?: boolean;
releaseUrl?: string;
macAddress?: string;
configurationUrl?: string;
manufacturer?: string;
meshRole?: TFritzMeshRole;
meshWifiUplink?: boolean;
connectionType?: string;
isRouter?: boolean;
wanEnabled?: boolean;
ipv6Active?: boolean;
upnpEnabled?: boolean;
services?: string[];
actions?: TFritzRouterAction[];
metadata?: Record<string, unknown>;
}
export interface IFritzConnectionInfo {
connection?: string;
wanEnabled?: boolean;
ipv6Active?: boolean;
isConnected?: boolean;
isLinked?: boolean;
externalIp?: string;
externalIPv6?: string;
externalIpv6?: string;
connectionUptime?: string | number | Date;
deviceUptime?: string | number | Date;
transmissionRate?: [number, number];
maxBitRate?: [number, number];
maxLinkedBitRate?: [number, number];
noiseMargin?: [number, number];
attenuation?: [number, number];
bytesSent?: number;
bytesReceived?: number;
kbSent?: number;
kbReceived?: number;
maxKbSent?: number;
maxKbReceived?: number;
linkKbSent?: number;
linkKbReceived?: number;
gbSent?: number;
gbReceived?: number;
cpuTemperature?: number;
[key: string]: unknown;
}
export interface IFritzSensorMap {
is_connected?: boolean;
is_linked?: boolean;
external_ip?: string;
external_ipv6?: string;
connection_uptime?: string | number | Date;
device_uptime?: string | number | Date;
kb_s_sent?: number;
kb_s_received?: number;
max_kb_s_sent?: number;
max_kb_s_received?: number;
gb_sent?: number;
gb_received?: number;
link_kb_s_sent?: number;
link_kb_s_received?: number;
link_noise_margin_sent?: number;
link_noise_margin_received?: number;
link_attenuation_sent?: number;
link_attenuation_received?: number;
cpu_temperature?: number;
[key: string]: string | number | boolean | Date | null | undefined;
}
export interface IFritzClientDevice {
id?: string;
mac?: string;
macAddress?: string;
name?: string;
hostname?: string;
ip?: string;
ipAddress?: string;
connected?: boolean;
connectedTo?: string;
connectionType?: string;
ssid?: string | null;
lastActivity?: string | number | Date;
wanAccess?: boolean | null;
manufacturer?: string;
model?: string;
actions?: TFritzClientAction[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IFritzInterfaceStats {
id?: string;
name: string;
label?: string;
type?: string;
connected?: boolean;
macAddress?: string;
ipAddress?: string;
ssid?: string | null;
opMode?: string;
rxBytes?: number;
txBytes?: number;
rxRateKbps?: number;
txRateKbps?: number;
downloadBytes?: number;
uploadBytes?: number;
downloadRateKbps?: number;
uploadRateKbps?: number;
metadata?: Record<string, unknown>;
}
export interface IFritzWifiNetwork {
id?: string;
index?: number;
name?: string;
switchName?: string;
ssid?: string;
enabled?: boolean;
guest?: boolean;
band?: string;
standard?: string;
bssid?: string;
macAddressControl?: boolean;
hidden?: boolean;
metadata?: Record<string, unknown>;
}
export interface IFritzPortForward {
id?: string;
index?: number;
connectionType?: string;
description?: string;
enabled?: boolean;
internalClient?: string;
internalPort?: number;
externalPort?: number;
protocol?: string;
remoteHost?: string;
leaseDuration?: number;
metadata?: Record<string, unknown>;
}
export interface IFritzCallDeflection {
id: string | number;
enabled?: boolean;
type?: string;
number?: string;
deflectionToNumber?: string;
mode?: string;
outgoing?: string;
phonebookId?: string | number;
metadata?: Record<string, unknown>;
}
export interface IFritzUpdateInfo {
installedVersion?: string;
latestVersion?: string;
releaseUrl?: string;
updateAvailable?: boolean;
metadata?: Record<string, unknown>;
}
export interface IFritzActionDescriptor {
target: 'router' | 'client' | 'wifi' | 'port_forward' | 'call_deflection' | 'service';
action: TFritzAction;
service?: string;
mac?: string;
entityId?: string;
deviceId?: string;
id?: string | number;
label?: string;
metadata?: Record<string, unknown>;
}
export interface IFritzSnapshot {
connected: boolean;
source?: TFritzSnapshotSource;
updatedAt?: string;
router: IFritzRouterInfo;
devices: IFritzClientDevice[];
interfaces: IFritzInterfaceStats[];
connection: IFritzConnectionInfo;
sensors: IFritzSensorMap;
wifiNetworks: IFritzWifiNetwork[];
portForwards: IFritzPortForward[];
callDeflections: IFritzCallDeflection[];
update?: IFritzUpdateInfo;
actions?: IFritzActionDescriptor[];
events?: IFritzEvent[];
error?: string;
metadata?: Record<string, unknown>;
}
export interface IFritzManualEntry {
id?: string;
host?: string;
port?: number;
ssl?: boolean;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
router?: IFritzRouterInfo;
devices?: IFritzClientDevice[];
clients?: IFritzClientDevice[];
interfaces?: IFritzInterfaceStats[];
connection?: IFritzConnectionInfo;
sensors?: IFritzSensorMap;
wifiNetworks?: IFritzWifiNetwork[];
portForwards?: IFritzPortForward[];
callDeflections?: IFritzCallDeflection[];
update?: IFritzUpdateInfo;
actions?: IFritzActionDescriptor[];
snapshot?: IFritzSnapshot;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IFritzManualDiscoveryRecord extends IFritzManualEntry {
integrationDomain?: string;
}
export interface IFritzSsdpDiscoveryRecord {
source?: string;
integrationDomain?: string;
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
ssdpLocation?: string;
ssdp_location?: string;
location?: string;
st?: string;
usn?: string;
udn?: string;
upnp?: Record<string, unknown>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IFritzMdnsDiscoveryRecord {
source?: string;
integrationDomain?: string;
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
serviceType?: string;
type?: string;
fullname?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IFritzCommand {
type: TFritzCommandType;
service: string;
action: TFritzAction;
target: {
entityId?: string;
deviceId?: string;
};
routerId?: string;
mac?: string;
entityId?: string;
deviceId?: string;
payload?: Record<string, unknown>;
}
export interface IFritzCommandResult extends IServiceCallResult {}
export interface IFritzEvent {
type: string;
timestamp?: number;
deviceId?: string;
entityId?: string;
command?: IFritzCommand;
snapshot?: IFritzSnapshot;
error?: string;
data?: unknown;
[key: string]: unknown;
}
export interface IFritzNativeClient {
getSnapshot(): Promise<IFritzSnapshot> | IFritzSnapshot;
executeCommand?(commandArg: IFritzCommand): Promise<IFritzCommandResult | unknown> | IFritzCommandResult | unknown;
destroy?(): Promise<void> | void;
}
export type TFritzSnapshotProvider = () => Promise<IFritzSnapshot | undefined> | IFritzSnapshot | undefined;
export type TFritzCommandExecutor = (
commandArg: IFritzCommand
) => Promise<IFritzCommandResult | unknown> | IFritzCommandResult | unknown;
+4
View File
@@ -1,2 +1,6 @@
export * from './fritz.classes.client.js';
export * from './fritz.classes.configflow.js';
export * from './fritz.classes.integration.js'; export * from './fritz.classes.integration.js';
export * from './fritz.discovery.js';
export * from './fritz.mapper.js';
export * from './fritz.types.js'; export * from './fritz.types.js';
+7 -13
View File
@@ -134,7 +134,6 @@ import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js'; import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
import { HomeAssistantBondIntegration } from '../bond/index.js'; import { HomeAssistantBondIntegration } from '../bond/index.js';
import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js'; import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
import { HomeAssistantBoschShcIntegration } from '../bosch_shc/index.js';
import { HomeAssistantBrandsIntegration } from '../brands/index.js'; import { HomeAssistantBrandsIntegration } from '../brands/index.js';
import { HomeAssistantBrandtIntegration } from '../brandt/index.js'; import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js'; import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
@@ -232,7 +231,6 @@ import { HomeAssistantDeviceAutomationIntegration } from '../device_automation/i
import { HomeAssistantDeviceSunLightTriggerIntegration } from '../device_sun_light_trigger/index.js'; import { HomeAssistantDeviceSunLightTriggerIntegration } from '../device_sun_light_trigger/index.js';
import { HomeAssistantDeviceTrackerIntegration } from '../device_tracker/index.js'; import { HomeAssistantDeviceTrackerIntegration } from '../device_tracker/index.js';
import { HomeAssistantDevoloHomeControlIntegration } from '../devolo_home_control/index.js'; import { HomeAssistantDevoloHomeControlIntegration } from '../devolo_home_control/index.js';
import { HomeAssistantDevoloHomeNetworkIntegration } from '../devolo_home_network/index.js';
import { HomeAssistantDexcomIntegration } from '../dexcom/index.js'; import { HomeAssistantDexcomIntegration } from '../dexcom/index.js';
import { HomeAssistantDhcpIntegration } from '../dhcp/index.js'; import { HomeAssistantDhcpIntegration } from '../dhcp/index.js';
import { HomeAssistantDiagnosticsIntegration } from '../diagnostics/index.js'; import { HomeAssistantDiagnosticsIntegration } from '../diagnostics/index.js';
@@ -387,7 +385,6 @@ import { HomeAssistantFreednsIntegration } from '../freedns/index.js';
import { HomeAssistantFreedomproIntegration } from '../freedompro/index.js'; import { HomeAssistantFreedomproIntegration } from '../freedompro/index.js';
import { HomeAssistantFreshrIntegration } from '../freshr/index.js'; import { HomeAssistantFreshrIntegration } from '../freshr/index.js';
import { HomeAssistantFressnapfTrackerIntegration } from '../fressnapf_tracker/index.js'; import { HomeAssistantFressnapfTrackerIntegration } from '../fressnapf_tracker/index.js';
import { HomeAssistantFritzIntegration } from '../fritz/index.js';
import { HomeAssistantFritzboxIntegration } from '../fritzbox/index.js'; import { HomeAssistantFritzboxIntegration } from '../fritzbox/index.js';
import { HomeAssistantFritzboxCallmonitorIntegration } from '../fritzbox_callmonitor/index.js'; import { HomeAssistantFritzboxCallmonitorIntegration } from '../fritzbox_callmonitor/index.js';
import { HomeAssistantFroniusIntegration } from '../fronius/index.js'; import { HomeAssistantFroniusIntegration } from '../fronius/index.js';
@@ -425,7 +422,6 @@ import { HomeAssistantGiosIntegration } from '../gios/index.js';
import { HomeAssistantGithubIntegration } from '../github/index.js'; import { HomeAssistantGithubIntegration } from '../github/index.js';
import { HomeAssistantGitlabCiIntegration } from '../gitlab_ci/index.js'; import { HomeAssistantGitlabCiIntegration } from '../gitlab_ci/index.js';
import { HomeAssistantGitterIntegration } from '../gitter/index.js'; import { HomeAssistantGitterIntegration } from '../gitter/index.js';
import { HomeAssistantGlancesIntegration } from '../glances/index.js';
import { HomeAssistantGo2rtcIntegration } from '../go2rtc/index.js'; import { HomeAssistantGo2rtcIntegration } from '../go2rtc/index.js';
import { HomeAssistantGoalzeroIntegration } from '../goalzero/index.js'; import { HomeAssistantGoalzeroIntegration } from '../goalzero/index.js';
import { HomeAssistantGogogate2Integration } from '../gogogate2/index.js'; import { HomeAssistantGogogate2Integration } from '../gogogate2/index.js';
@@ -477,7 +473,6 @@ import { HomeAssistantHeatmiserIntegration } from '../heatmiser/index.js';
import { HomeAssistantHegelIntegration } from '../hegel/index.js'; import { HomeAssistantHegelIntegration } from '../hegel/index.js';
import { HomeAssistantHeickoIntegration } from '../heicko/index.js'; import { HomeAssistantHeickoIntegration } from '../heicko/index.js';
import { HomeAssistantHeiwaIntegration } from '../heiwa/index.js'; import { HomeAssistantHeiwaIntegration } from '../heiwa/index.js';
import { HomeAssistantHeosIntegration } from '../heos/index.js';
import { HomeAssistantHereTravelTimeIntegration } from '../here_travel_time/index.js'; import { HomeAssistantHereTravelTimeIntegration } from '../here_travel_time/index.js';
import { HomeAssistantHexaomIntegration } from '../hexaom/index.js'; import { HomeAssistantHexaomIntegration } from '../hexaom/index.js';
import { HomeAssistantHiKumoIntegration } from '../hi_kumo/index.js'; import { HomeAssistantHiKumoIntegration } from '../hi_kumo/index.js';
@@ -576,7 +571,6 @@ import { HomeAssistantIotawattIntegration } from '../iotawatt/index.js';
import { HomeAssistantIottyIntegration } from '../iotty/index.js'; import { HomeAssistantIottyIntegration } from '../iotty/index.js';
import { HomeAssistantIperf3Integration } from '../iperf3/index.js'; import { HomeAssistantIperf3Integration } from '../iperf3/index.js';
import { HomeAssistantIpmaIntegration } from '../ipma/index.js'; import { HomeAssistantIpmaIntegration } from '../ipma/index.js';
import { HomeAssistantIppIntegration } from '../ipp/index.js';
import { HomeAssistantIqviaIntegration } from '../iqvia/index.js'; import { HomeAssistantIqviaIntegration } from '../iqvia/index.js';
import { HomeAssistantIrishRailTransportIntegration } from '../irish_rail_transport/index.js'; import { HomeAssistantIrishRailTransportIntegration } from '../irish_rail_transport/index.js';
import { HomeAssistantIrmKmiIntegration } from '../irm_kmi/index.js'; import { HomeAssistantIrmKmiIntegration } from '../irm_kmi/index.js';
@@ -1542,7 +1536,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIn
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschShcIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
@@ -1640,7 +1633,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationInt
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceSunLightTriggerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceSunLightTriggerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceTrackerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceTrackerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevoloHomeControlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevoloHomeControlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevoloHomeNetworkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDexcomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDexcomIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDhcpIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDhcpIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiagnosticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiagnosticsIntegration());
@@ -1795,7 +1787,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreednsIntegration(
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreedomproIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreedomproIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreshrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreshrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFressnapfTrackerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFressnapfTrackerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxCallmonitorIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxCallmonitorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration());
@@ -1833,7 +1824,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantGiosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGithubIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGithubIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitlabCiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitlabCiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitterIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGlancesIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGo2rtcIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGo2rtcIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoalzeroIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoalzeroIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGogogate2Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantGogogate2Integration());
@@ -1885,7 +1875,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeatmiserIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHegelIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHegelIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeickoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeickoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeiwaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeiwaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHeosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHereTravelTimeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHereTravelTimeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHexaomIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHexaomIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHiKumoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHiKumoIntegration());
@@ -1984,7 +1973,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantIotawattIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIottyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIottyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIperf3Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIperf3Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIpmaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIpmaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIppIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIqviaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIqviaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrishRailTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrishRailTransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrmKmiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantIrmKmiIntegration());
@@ -2816,7 +2804,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1406; export const generatedHomeAssistantPortCount = 1400;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"adguard", "adguard",
"airgradient", "airgradient",
@@ -2830,17 +2818,23 @@ export const handwrittenHomeAssistantPortDomains = [
"axis", "axis",
"blebox", "blebox",
"bluetooth_le_tracker", "bluetooth_le_tracker",
"bosch_shc",
"braviatv", "braviatv",
"broadlink", "broadlink",
"cast", "cast",
"deconz", "deconz",
"denonavr", "denonavr",
"devolo_home_network",
"dlna_dmr", "dlna_dmr",
"dsmr", "dsmr",
"esphome", "esphome",
"fritz",
"glances",
"heos",
"homekit_controller", "homekit_controller",
"homematic", "homematic",
"hue", "hue",
"ipp",
"jellyfin", "jellyfin",
"knx", "knx",
"kodi", "kodi",
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,176 @@
import { GlancesMapper } from './glances.mapper.js';
import type { IGlancesConfig, IGlancesRawData, IGlancesRefreshResult, IGlancesSnapshot, TGlancesApiVersion } from './glances.types.js';
import { glancesDefaultPort, glancesDefaultTimeoutMs } from './glances.types.js';
export class GlancesApiError extends Error {}
export class GlancesApiConnectionError extends GlancesApiError {}
export class GlancesApiAuthorizationError extends GlancesApiError {}
export class GlancesApiNoDataAvailableError extends GlancesApiError {}
export class GlancesClient {
private currentSnapshot?: IGlancesSnapshot;
constructor(private readonly config: IGlancesConfig) {}
public async getSnapshot(): Promise<IGlancesSnapshot> {
if (this.hasManualData()) {
this.currentSnapshot = GlancesMapper.toSnapshot({ config: this.config, source: this.config.snapshot ? 'snapshot' : 'manual', online: this.config.online ?? true });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.host) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('No Glances HTTP endpoint or snapshot data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IGlancesRefreshResult> {
if (this.hasManualData()) {
const snapshot = await this.getSnapshot();
return { success: true, snapshot, data: { source: snapshot.source } };
}
if (!this.config.host) {
const snapshot = await this.getSnapshot();
return {
success: false,
snapshot,
error: 'Glances refresh requires a configured HTTP endpoint or snapshot/manual data.',
};
}
try {
const snapshot = await this.fetchSnapshot();
this.currentSnapshot = snapshot;
return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'http', apiVersion: snapshot.apiVersion } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = this.offlineSnapshot(error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
}
}
public async ping(): Promise<boolean> {
if (this.hasManualData()) {
return true;
}
if (!this.config.host) {
return false;
}
return (await this.refresh()).success;
}
public async destroy(): Promise<void> {}
public async fetchSnapshot(): Promise<IGlancesSnapshot> {
const versions = this.apiVersions();
let lastNoDataError: Error | undefined;
for (const version of versions) {
try {
const rawData = await this.requestRawData(version);
return GlancesMapper.toSnapshot({
config: this.config,
rawData,
online: true,
source: 'http',
apiVersion: version,
});
} catch (errorArg) {
if (errorArg instanceof GlancesApiNoDataAvailableError && versions.length > 1) {
lastNoDataError = errorArg;
continue;
}
throw errorArg;
}
}
throw lastNoDataError || new GlancesApiNoDataAvailableError('Could not connect to Glances API version 3 or 4.');
}
private async requestRawData(versionArg: TGlancesApiVersion): Promise<IGlancesRawData> {
if (this.config.password && !this.config.username) {
throw new GlancesApiAuthorizationError('Glances username and password must both be provided for HTTP basic authentication.');
}
const url = `${this.baseUrl(versionArg)}/all`;
let response: Response;
try {
response = await globalThis.fetch(url, {
method: 'GET',
headers: this.headers(),
signal: AbortSignal.timeout(this.config.timeoutMs || glancesDefaultTimeoutMs),
});
} catch (errorArg) {
throw new GlancesApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
}
if (response.status === 401) {
throw new GlancesApiAuthorizationError('Please check your Glances credentials.');
}
if (!response.ok) {
throw new GlancesApiNoDataAvailableError(`Glances endpoint /api/${versionArg}/all is not valid: HTTP ${response.status}.`);
}
const text = await response.text();
try {
const parsed = text ? JSON.parse(text) as unknown : {};
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('response is not a JSON object');
}
return parsed as IGlancesRawData;
} catch (errorArg) {
throw new GlancesApiConnectionError(`Unable to parse Glances data from ${url}: ${this.errorMessage(errorArg)}`);
}
}
private headers(): Record<string, string> {
const headers: Record<string, string> = { accept: 'application/json' };
if (this.config.username && this.config.password) {
headers.authorization = `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`;
}
return headers;
}
private baseUrl(versionArg: TGlancesApiVersion): string {
const protocol = this.config.ssl ? 'https' : 'http';
const host = this.config.host || 'localhost';
const port = this.config.port || glancesDefaultPort;
return `${protocol}://${this.hostForUrl(host)}:${port}/api/${versionArg}`;
}
private hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private apiVersions(): TGlancesApiVersion[] {
const configuredVersion = this.config.apiVersion || this.config.version;
return configuredVersion ? [configuredVersion] : [4, 3];
}
private offlineSnapshot(errorArg: string): IGlancesSnapshot {
return GlancesMapper.toSnapshot({
config: this.config,
online: false,
source: 'runtime',
error: errorArg,
});
}
private hasManualData(): boolean {
return Boolean(this.config.snapshot || this.config.rawData || this.config.allData || this.config.haSensorData || this.config.sensorData);
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IGlancesSnapshot): IGlancesSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IGlancesSnapshot;
}
}
@@ -0,0 +1,133 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IGlancesConfig, IGlancesHaSensorData, IGlancesRawData, IGlancesSnapshot, TGlancesApiVersion } from './glances.types.js';
import { glancesDefaultPort, glancesDefaultTimeoutMs } from './glances.types.js';
export class GlancesConfigFlow implements IConfigFlow<IGlancesConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IGlancesConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Glances',
description: 'Configure a local Glances REST API endpoint, or use snapshot/manual data from the discovery candidate.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'apiVersion', label: 'API version', type: 'select', options: [
{ label: 'Auto (v4 then v3)', value: 'auto' },
{ label: 'v4', value: '4' },
{ label: 'v3', value: '3' },
] },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IGlancesConfig>> {
const metadata = candidateArg.metadata || {};
const host = this.stringValue(valuesArg.host) || candidateArg.host;
const port = this.numberValue(valuesArg.port) || candidateArg.port || glancesDefaultPort;
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(metadata.ssl) ?? false;
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? false;
const username = this.stringValue(valuesArg.username) || this.stringValue(metadata.username);
const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password);
const apiVersion = this.apiVersionValue(valuesArg.apiVersion) || this.apiVersionValue(metadata.apiVersion);
const snapshot = this.snapshotValue(metadata.snapshot);
const rawData = this.rawDataValue(metadata.rawData || metadata.allData);
const haSensorData = this.sensorDataValue(metadata.haSensorData || metadata.sensorData);
if (!host && !snapshot && !rawData && !haSensorData) {
return { kind: 'error', title: 'Glances setup failed', error: 'Glances host or snapshot/manual sensor data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'Glances setup failed', error: 'Glances port must be between 1 and 65535.' };
}
if (password && !username) {
return { kind: 'error', title: 'Glances setup failed', error: 'Glances username is required when password is provided.' };
}
const config: IGlancesConfig = {
host,
port,
ssl,
verifySsl,
username,
password,
apiVersion,
name: this.stringValue(valuesArg.name) || candidateArg.name || this.stringValue(metadata.name),
uniqueId: candidateArg.id || (host ? `${host}:${port}` : undefined),
timeoutMs: glancesDefaultTimeoutMs,
snapshot,
rawData,
haSensorData,
};
return {
kind: 'done',
title: 'Glances configured',
config,
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (valueArg.toLowerCase() === 'true') {
return true;
}
if (valueArg.toLowerCase() === 'false') {
return false;
}
}
return undefined;
}
private apiVersionValue(valueArg: unknown): TGlancesApiVersion | undefined {
const value = typeof valueArg === 'number' ? String(valueArg) : this.stringValue(valueArg);
if (value === '3' || value === '4') {
return Number(value) as TGlancesApiVersion;
}
return undefined;
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
private snapshotValue(valueArg: unknown): IGlancesSnapshot | undefined {
return this.isRecord(valueArg) && 'host' in valueArg && 'sensorData' in valueArg ? valueArg as unknown as IGlancesSnapshot : undefined;
}
private rawDataValue(valueArg: unknown): IGlancesRawData | undefined {
return this.isRecord(valueArg) ? valueArg as IGlancesRawData : undefined;
}
private sensorDataValue(valueArg: unknown): IGlancesHaSensorData | undefined {
return this.isRecord(valueArg) ? valueArg as IGlancesHaSensorData : undefined;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -1,26 +1,95 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { GlancesClient } from './glances.classes.client.js';
import { GlancesConfigFlow } from './glances.classes.configflow.js';
import { createGlancesDiscoveryDescriptor } from './glances.discovery.js';
import { GlancesMapper } from './glances.mapper.js';
import type { IGlancesConfig } from './glances.types.js';
import { glancesDomain } from './glances.types.js';
export class HomeAssistantGlancesIntegration extends DescriptorOnlyIntegration { export class GlancesIntegration extends BaseIntegration<IGlancesConfig> {
constructor() { public readonly domain = glancesDomain;
super({ public readonly displayName = 'Glances';
domain: "glances", public readonly status = 'read-only-runtime' as const;
displayName: "Glances", public readonly discoveryDescriptor = createGlancesDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new GlancesConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/glances", upstreamPath: 'homeassistant/components/glances',
"upstreamDomain": "glances", upstreamDomain: glancesDomain,
"integrationType": "service", integrationType: 'service',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['glances-api==0.10.0'],
"glances-api==0.10.0" dependencies: [] as string[],
], afterDependencies: [] as string[],
"dependencies": [], codeowners: ['@engrbm87'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/glances',
"@engrbm87" discovery: {
] manual: true,
}, http: 'Manual local HTTP endpoint candidates are recognized; no active LAN scan is performed.',
}); note: 'Glances has no HA automatic discovery. Configure host/port or provide raw /api/{version}/all data, HA sensor data, or a snapshot.',
},
runtime: {
type: 'read-only-runtime',
polling: 'local HTTP Glances REST API /api/{version}/all',
services: ['snapshot', 'status', 'refresh'],
controls: false,
},
localApi: {
implemented: [
'Glances REST API v4/v3 probing against /api/{version}/all',
'native mapping compatible with glances-api get_ha_sensor_data for CPU, memory, disk, network, load, temperature, process, container, GPU, and RAID sensors',
'manual raw API data, HA sensor data, and snapshot inputs',
'read-only refresh service',
],
explicitUnsupported: [
'Home Assistant Python glances_api compatibility wrapper',
'write/control services',
'fake live API success without a configured HTTP endpoint or snapshot/manual data',
],
},
};
public async setup(configArg: IGlancesConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new GlancesRuntime(new GlancesClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantGlancesIntegration extends GlancesIntegration {}
class GlancesRuntime implements IIntegrationRuntime {
public domain = glancesDomain;
constructor(private readonly client: GlancesClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return GlancesMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return GlancesMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain !== glancesDomain) {
return { success: false, error: `Unsupported Glances service domain: ${requestArg.domain}` };
}
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'refresh' || requestArg.service === 'reload') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot || result.data };
}
return { success: false, error: `Unsupported Glances service: ${requestArg.service}` };
}
public async destroy(): Promise<void> {
await this.client.destroy();
} }
} }
@@ -0,0 +1,193 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IGlancesHttpCandidateRecord, IGlancesManualEntry, IGlancesSnapshot } from './glances.types.js';
import { glancesDefaultPort, glancesDomain } from './glances.types.js';
export class GlancesManualMatcher implements IDiscoveryMatcher<IGlancesManualEntry> {
public id = 'glances-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Glances local HTTP and snapshot setup entries.';
public async matches(inputArg: IGlancesManualEntry): Promise<IDiscoveryMatch> {
const parsedUrl = parseUrl(inputArg.url);
const metadata = inputArg.metadata || {};
const hasManualData = Boolean(inputArg.snapshot || inputArg.rawData || inputArg.allData || inputArg.haSensorData || inputArg.sensorData || metadata.snapshot || metadata.rawData || metadata.haSensorData || metadata.sensorData);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(inputArg.host || parsedUrl || inputArg.port === glancesDefaultPort || metadata.glances || hasManualData || text.includes('glances') || text.includes('system monitor'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Glances setup hints.' };
}
const host = inputArg.host || parsedUrl?.host;
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanMetadata(metadata.ssl) ?? false;
const port = inputArg.port || parsedUrl?.port || glancesDefaultPort;
const id = inputArg.id || this.snapshotId(inputArg.snapshot || metadata.snapshot) || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start Glances setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: glancesDomain,
id,
host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Glances',
model: inputArg.model || 'System Monitor',
metadata: {
...metadata,
glances: true,
ssl,
verifySsl: inputArg.verifySsl ?? metadata.verifySsl,
username: inputArg.username ?? metadata.username,
password: inputArg.password ?? metadata.password,
apiVersion: inputArg.apiVersion || inputArg.version || metadata.apiVersion || parsedUrl?.apiVersion,
url: inputArg.url,
hasManualData,
snapshot: inputArg.snapshot || metadata.snapshot,
rawData: inputArg.rawData || inputArg.allData || metadata.rawData || metadata.allData,
haSensorData: inputArg.haSensorData || inputArg.sensorData || metadata.haSensorData || metadata.sensorData,
},
},
};
}
private snapshotId(valueArg: unknown): string | undefined {
const snapshot = isGlancesSnapshot(valueArg) ? valueArg : undefined;
return snapshot?.host.id || snapshot?.host.hostname || snapshot?.host.host;
}
}
export class GlancesHttpMatcher implements IDiscoveryMatcher<IGlancesHttpCandidateRecord> {
public id = 'glances-http-match';
public source = 'http' as const;
public description = 'Recognize local HTTP candidates that point at a Glances REST API.';
public async matches(recordArg: IGlancesHttpCandidateRecord): Promise<IDiscoveryMatch> {
const url = recordArg.url || recordArg.location;
const parsedUrl = parseUrl(url);
const headers = normalizeKeys(recordArg.headers || {});
const metadata = recordArg.metadata || {};
const text = [url, recordArg.name, recordArg.manufacturer, recordArg.model, headers.server, headers['x-powered-by']].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(parsedUrl?.apiVersion || parsedUrl?.port === glancesDefaultPort || recordArg.port === glancesDefaultPort || metadata.glances || text.includes('glances'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Glances API.' };
}
const host = recordArg.host || parsedUrl?.host;
const ssl = recordArg.ssl ?? parsedUrl?.ssl ?? false;
const port = recordArg.port || parsedUrl?.port || glancesDefaultPort;
const id = host ? `${host}:${port}` : undefined;
return {
matched: true,
confidence: parsedUrl?.apiVersion && host ? 'high' : host ? 'medium' : 'low',
reason: 'HTTP candidate has Glances API hints.',
normalizedDeviceId: id,
candidate: {
source: 'http',
integrationDomain: glancesDomain,
id,
host,
port,
name: recordArg.name || 'Glances',
manufacturer: recordArg.manufacturer || 'Glances',
model: recordArg.model || 'System Monitor',
metadata: {
...metadata,
glances: true,
ssl,
url,
apiVersion: parsedUrl?.apiVersion,
headers,
},
},
};
}
}
export class GlancesCandidateValidator implements IDiscoveryValidator {
public id = 'glances-candidate-validator';
public description = 'Validate Glances candidates have a usable HTTP endpoint or snapshot/manual data.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === glancesDomain
|| Boolean(metadata.glances)
|| candidateArg.port === glancesDefaultPort
|| text.includes('glances')
|| text.includes('system monitor');
const hasManualData = Boolean(metadata.snapshot || metadata.rawData || metadata.allData || metadata.haSensorData || metadata.sensorData);
const port = candidateArg.port || glancesDefaultPort;
const hasUsableAddress = Boolean(candidateArg.host && isValidPort(port));
if (!matched || (!hasUsableAddress && !hasManualData)) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Glances candidate lacks a usable host or snapshot/manual data.' : 'Candidate is not Glances.',
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined),
};
}
const normalizedDeviceId = candidateArg.id || snapshotId(metadata.snapshot) || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined);
return {
matched: true,
confidence: normalizedDeviceId && hasUsableAddress ? 'certain' : hasUsableAddress ? 'high' : 'medium',
reason: 'Candidate has Glances metadata and a usable HTTP endpoint or snapshot/manual data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: glancesDomain,
port,
manufacturer: candidateArg.manufacturer || 'Glances',
model: candidateArg.model || 'System Monitor',
},
};
}
}
export const createGlancesDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: glancesDomain, displayName: 'Glances' })
.addMatcher(new GlancesManualMatcher())
.addMatcher(new GlancesHttpMatcher())
.addValidator(new GlancesCandidateValidator());
};
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; apiVersion?: 3 | 4 } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
const versionMatch = url.pathname.match(/\/api\/(3|4)(?:\/|$)/);
return {
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
ssl: url.protocol === 'https:',
apiVersion: versionMatch ? Number(versionMatch[1]) as 3 | 4 : undefined,
};
} catch {
return undefined;
}
};
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(recordArg)) {
normalized[key.toLowerCase()] = value;
}
return normalized;
};
const booleanMetadata = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined;
const isValidPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
const isGlancesSnapshot = (valueArg: unknown): valueArg is IGlancesSnapshot => Boolean(valueArg && typeof valueArg === 'object' && 'host' in valueArg && 'sensorData' in valueArg);
const snapshotId = (valueArg: unknown): string | undefined => {
const snapshot = isGlancesSnapshot(valueArg) ? valueArg : undefined;
return snapshot?.host.id || snapshot?.host.hostname || snapshot?.host.host;
};
+598
View File
@@ -0,0 +1,598 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type {
IGlancesConfig,
IGlancesDiskIoSensorData,
IGlancesDiskSensorData,
IGlancesEnvironmentSensorData,
IGlancesGpuSensorData,
IGlancesHaSensorData,
IGlancesHostInfo,
IGlancesNetworkSensorData,
IGlancesRawData,
IGlancesSensorState,
IGlancesSnapshot,
TGlancesApiVersion,
TGlancesSensorCategory,
TGlancesSensorValue,
TGlancesSnapshotSource,
} from './glances.types.js';
import { glancesDefaultPort, glancesDomain } from './glances.types.js';
interface IGlancesSnapshotOptions {
config: IGlancesConfig;
sensorData?: IGlancesHaSensorData;
rawData?: IGlancesRawData;
online?: boolean;
source?: TGlancesSnapshotSource;
apiVersion?: TGlancesApiVersion;
error?: string;
}
interface IGlancesSensorDefinition {
key: string;
name: string;
category: TGlancesSensorCategory;
value: unknown;
unit?: string;
label?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
numeric?: boolean;
attributes?: Record<string, unknown>;
}
export class GlancesMapper {
public static toSnapshot(optionsArg: IGlancesSnapshotOptions): IGlancesSnapshot {
if (optionsArg.config.snapshot) {
return this.normalizeSnapshot(optionsArg.config.snapshot, optionsArg.config, optionsArg.source || 'snapshot');
}
const rawData = optionsArg.rawData || optionsArg.config.rawData || optionsArg.config.allData;
const apiVersion = optionsArg.apiVersion || optionsArg.config.apiVersion || optionsArg.config.version;
const sensorData = optionsArg.sensorData
|| optionsArg.config.haSensorData
|| optionsArg.config.sensorData
|| (rawData ? this.haSensorDataFromRawData(rawData, apiVersion || 4) : {});
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData || Object.keys(sensorData).length);
const host = this.hostInfo(optionsArg.config, rawData, apiVersion);
const updatedAt = new Date().toISOString();
const snapshot: IGlancesSnapshot = {
host,
sensorData: this.clone(sensorData),
sensors: this.sensorsFromHaSensorData(sensorData),
rawData: rawData ? this.clone(rawData) : undefined,
online,
updatedAt,
source: optionsArg.source || (rawData || Object.keys(sensorData).length ? 'manual' : 'runtime'),
apiVersion,
error: optionsArg.error,
};
return this.normalizeSnapshot(snapshot, optionsArg.config, snapshot.source || 'runtime');
}
public static toDevices(snapshotArg: IGlancesSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
return [{
id: this.hostDeviceId(snapshotArg),
integrationDomain: glancesDomain,
name: this.hostName(snapshotArg),
protocol: snapshotArg.host.host ? 'http' : 'unknown',
manufacturer: 'Glances',
model: snapshotArg.host.osName || snapshotArg.host.platform || 'System Monitor',
online: snapshotArg.online,
features: [
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
...snapshotArg.sensors.map((sensorArg) => ({
id: sensorArg.key,
capability: 'sensor' as const,
name: sensorArg.name,
readable: true,
writable: false,
unit: sensorArg.unit,
})),
],
state: [
{ featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
...snapshotArg.sensors.map((sensorArg) => ({ featureId: sensorArg.key, value: sensorArg.value, updatedAt })),
],
metadata: this.cleanAttributes({
host: snapshotArg.host.host,
port: snapshotArg.host.port,
ssl: snapshotArg.host.ssl,
apiVersion: snapshotArg.apiVersion || snapshotArg.host.apiVersion,
hostname: snapshotArg.host.hostname,
platform: snapshotArg.host.platform,
version: snapshotArg.host.version,
source: snapshotArg.source,
rawPlugins: snapshotArg.rawData ? Object.keys(snapshotArg.rawData).sort() : undefined,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IGlancesSnapshot): IIntegrationEntity[] {
const deviceId = this.hostDeviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const usedIds = new Map<string, number>();
return snapshotArg.sensors.map((sensorArg) => {
const id = this.entityId(this.hostName(snapshotArg), sensorArg, usedIds);
return {
id,
uniqueId: `${glancesDomain}_${uniqueBase}_${sensorArg.key}`,
integrationDomain: glancesDomain,
deviceId,
platform: 'sensor',
name: sensorArg.name,
state: sensorArg.value,
attributes: this.cleanAttributes({
key: sensorArg.key,
category: sensorArg.category,
label: sensorArg.label,
unitOfMeasurement: sensorArg.unit,
deviceClass: sensorArg.deviceClass,
stateClass: sensorArg.stateClass,
entityCategory: sensorArg.entityCategory,
source: snapshotArg.source,
apiVersion: snapshotArg.apiVersion,
...sensorArg.attributes,
}),
available: snapshotArg.online && sensorArg.available,
};
});
}
public static haSensorDataFromRawData(rawDataArg: IGlancesRawData, apiVersionArg: TGlancesApiVersion): IGlancesHaSensorData {
const sensorData: IGlancesHaSensorData = {};
const fsData = this.arrayRecords(rawDataArg.fs);
if (fsData.length) {
sensorData.fs = {};
for (const disk of fsData) {
const label = this.stringValue(disk.mnt_point) || this.stringValue(disk.mount_point) || this.stringValue(disk.device_name);
const used = this.numberValue(disk.used);
const size = this.numberValue(disk.size);
const percent = this.numberValue(disk.percent);
const free = this.numberValue(disk.free) ?? (size !== undefined && used !== undefined ? size - used : undefined);
if (!label) {
continue;
}
sensorData.fs[label] = this.cleanAttributes({
disk_use: used !== undefined ? this.round(used / 1024 ** 3, 1) : undefined,
disk_use_percent: percent,
disk_size: size !== undefined ? this.round(size / 1024 ** 3, 1) : undefined,
disk_free: free !== undefined ? this.round(free / 1024 ** 3, 1) : undefined,
}) as IGlancesDiskSensorData;
}
}
const rawSensors = this.arrayRecords(rawDataArg.sensors);
if (rawSensors.length) {
sensorData.sensors = {};
for (const sensor of rawSensors) {
const label = this.stringValue(sensor.label);
const type = this.stringValue(sensor.type);
const value = this.numberValue(sensor.value);
if (!label || !type || value === undefined) {
continue;
}
sensorData.sensors[label] = {
...(sensorData.sensors[label] || {}),
[type]: value,
};
}
}
const mem = this.recordValue(rawDataArg.mem);
if (mem) {
sensorData.mem = this.cleanAttributes({
memory_use_percent: this.numberValue(mem.percent),
memory_use: this.bytesToMib(mem.used),
memory_free: this.bytesToMib(mem.free),
});
}
const memswap = this.recordValue(rawDataArg.memswap);
if (memswap) {
sensorData.memswap = this.cleanAttributes({
swap_use_percent: this.numberValue(memswap.percent),
swap_use: this.bytesToGib(memswap.used),
swap_free: this.bytesToGib(memswap.free),
});
}
const load = this.recordValue(rawDataArg.load);
if (load) {
sensorData.load = this.cleanAttributes({
processor_load: this.numberValue(load.min15),
processor_load_1m: this.numberValue(load.min1),
processor_load_5m: this.numberValue(load.min5),
});
}
const processcount = this.recordValue(rawDataArg.processcount);
if (processcount) {
sensorData.processcount = this.cleanAttributes({
process_running: this.numberValue(processcount.running),
process_total: this.numberValue(processcount.total),
process_thread: this.numberValue(processcount.thread),
process_sleeping: this.numberValue(processcount.sleeping),
});
}
const quicklook = this.recordValue(rawDataArg.quicklook);
const cpu = this.numberValue(quicklook?.cpu);
if (cpu !== undefined) {
sensorData.cpu = { cpu_use_percent: cpu };
}
const percpu = this.arrayRecords(rawDataArg.percpu);
if (percpu.length) {
sensorData.percpu = {};
for (const item of percpu) {
const cpuNumber = this.stringValue(item.cpu_number) || this.stringValue(item.key);
const total = this.numberValue(item.total);
if (cpuNumber && total !== undefined) {
sensorData.percpu[cpuNumber] = { cpu_use_percent: total };
}
}
}
const networks = this.arrayRecords(rawDataArg.network);
if (networks.length) {
sensorData.network = {};
for (const network of networks) {
const label = this.stringValue(network.interface_name) || this.stringValue(network.name);
if (!label) {
continue;
}
let rx: number | undefined;
let tx: number | undefined;
if (apiVersionArg <= 3) {
const timeSinceUpdate = this.numberValue(network.time_since_update);
if (timeSinceUpdate && timeSinceUpdate > 0) {
const rxBytes = this.numberValue(network.rx);
const txBytes = this.numberValue(network.tx);
rx = rxBytes !== undefined ? Math.round(rxBytes / timeSinceUpdate) : undefined;
tx = txBytes !== undefined ? Math.round(txBytes / timeSinceUpdate) : undefined;
}
} else {
rx = this.numberValue(network.bytes_recv_rate_per_sec);
tx = this.numberValue(network.bytes_sent_rate_per_sec);
}
sensorData.network[label] = this.cleanAttributes({
is_up: this.booleanValue(network.is_up),
rx,
tx,
speed: this.bytesToGib(network.speed),
}) as IGlancesNetworkSensorData;
}
}
const containersData = apiVersionArg <= 3
? this.arrayRecords(this.recordValue(rawDataArg.dockers)?.containers || this.recordValue(rawDataArg.containers)?.containers)
: this.arrayRecords(rawDataArg.containers);
if (containersData.length) {
const activeContainers = containersData.filter((containerArg) => {
const status = this.stringValue(containerArg.status || containerArg.Status)?.toLowerCase();
return status === 'running' || status === 'healthy';
});
sensorData.docker = {
docker_active: activeContainers.length,
docker_cpu_use: this.round(activeContainers.reduce((sumArg, containerArg) => sumArg + (this.numberValue(this.recordValue(containerArg.cpu)?.total) || 0), 0), 1),
docker_memory_use: this.round(activeContainers.reduce((sumArg, containerArg) => sumArg + (this.numberValue(this.recordValue(containerArg.memory)?.usage) || 0), 0) / 1024 ** 2, 1),
};
sensorData.containers = {};
for (const container of activeContainers) {
const name = this.stringValue(container.name);
if (!name) {
continue;
}
sensorData.containers[name] = {
container_cpu_use: this.round(this.numberValue(this.recordValue(container.cpu)?.total) || 0, 1),
container_memory_use: this.round((this.numberValue(this.recordValue(container.memory)?.usage) || 0) / 1024 ** 2, 1),
};
}
}
const raid = this.recordValue(rawDataArg.raid);
if (raid) {
sensorData.raid = raid as Record<string, Record<string, unknown>>;
}
if (rawDataArg.uptime !== undefined) {
sensorData.uptime = rawDataArg.uptime;
}
const gpu = this.arrayRecords(rawDataArg.gpu);
if (gpu.length) {
sensorData.gpu = {};
for (const item of gpu) {
const name = this.stringValue(item.name) || 'GPU';
const gpuId = this.stringValue(item.gpu_id) || this.stringValue(item.id) || '0';
sensorData.gpu[`${name} (GPU ${gpuId})`] = this.cleanAttributes({
temperature: this.numberValue(item.temperature) || 0,
mem: this.numberValue(item.mem) || 0,
proc: this.numberValue(item.proc) || 0,
fan_speed: this.numberValue(item.fan_speed) || 0,
}) as IGlancesGpuSensorData;
}
}
const diskio = this.arrayRecords(rawDataArg.diskio);
if (diskio.length) {
sensorData.diskio = {};
for (const disk of diskio) {
const name = this.stringValue(disk.disk_name) || this.stringValue(disk.name);
const timeSinceUpdate = this.numberValue(disk.time_since_update);
if (!name || !timeSinceUpdate || timeSinceUpdate <= 0) {
continue;
}
const readBytes = this.numberValue(disk.read_bytes);
const writeBytes = this.numberValue(disk.write_bytes);
sensorData.diskio[name] = this.cleanAttributes({
read: readBytes !== undefined ? Math.round(readBytes / timeSinceUpdate) : undefined,
write: writeBytes !== undefined ? Math.round(writeBytes / timeSinceUpdate) : undefined,
}) as IGlancesDiskIoSensorData;
}
}
return sensorData;
}
public static sensorsFromHaSensorData(sensorDataArg: IGlancesHaSensorData): IGlancesSensorState[] {
const sensors: IGlancesSensorState[] = [];
this.pushNumberSensor(sensors, { key: 'cpu_use_percent', name: 'CPU Usage', category: 'cpu', value: sensorDataArg.cpu?.cpu_use_percent, unit: '%', stateClass: 'measurement' });
for (const [label, cpu] of Object.entries(sensorDataArg.percpu || {})) {
this.pushNumberSensor(sensors, { key: `cpu_${this.slug(label)}_use_percent`, name: `CPU ${label} Usage`, category: 'cpu', value: cpu.cpu_use_percent, unit: '%', label, stateClass: 'measurement', entityCategory: 'diagnostic' });
}
this.pushNumberSensor(sensors, { key: 'memory_use_percent', name: 'Memory Usage', category: 'memory', value: sensorDataArg.mem?.memory_use_percent, unit: '%', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'memory_use', name: 'Memory Use', category: 'memory', value: sensorDataArg.mem?.memory_use, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'memory_free', name: 'Memory Free', category: 'memory', value: sensorDataArg.mem?.memory_free, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'swap_use_percent', name: 'Swap Usage', category: 'memory', value: sensorDataArg.memswap?.swap_use_percent, unit: '%', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'swap_use', name: 'Swap Use', category: 'memory', value: sensorDataArg.memswap?.swap_use, unit: 'GiB', deviceClass: 'data_size', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'swap_free', name: 'Swap Free', category: 'memory', value: sensorDataArg.memswap?.swap_free, unit: 'GiB', deviceClass: 'data_size', stateClass: 'measurement' });
for (const [label, disk] of Object.entries(sensorDataArg.fs || {})) {
const labelSlug = this.slug(label);
this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_use_percent`, name: `${label} Disk Usage`, category: 'disk', value: disk.disk_use_percent, unit: '%', label, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_used`, name: `${label} Disk Used`, category: 'disk', value: disk.disk_use, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_size`, name: `${label} Disk Size`, category: 'disk', value: disk.disk_size, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `disk_${labelSlug}_free`, name: `${label} Disk Free`, category: 'disk', value: disk.disk_free, unit: 'GiB', label, deviceClass: 'data_size', stateClass: 'measurement' });
}
for (const [label, disk] of Object.entries(sensorDataArg.diskio || {})) {
const labelSlug = this.slug(label);
this.pushNumberSensor(sensors, { key: `diskio_${labelSlug}_read`, name: `${label} Disk Read`, category: 'disk', value: disk.read, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `diskio_${labelSlug}_write`, name: `${label} Disk Write`, category: 'disk', value: disk.write, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement' });
}
for (const [label, network] of Object.entries(sensorDataArg.network || {})) {
const labelSlug = this.slug(label);
const attributes = { isUp: network.is_up, speedGib: network.speed };
this.pushNumberSensor(sensors, { key: `network_${labelSlug}_rx`, name: `${label} RX`, category: 'network', value: network.rx, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement', attributes });
this.pushNumberSensor(sensors, { key: `network_${labelSlug}_tx`, name: `${label} TX`, category: 'network', value: network.tx, unit: 'B/s', label, deviceClass: 'data_rate', stateClass: 'measurement', attributes });
}
this.pushNumberSensor(sensors, { key: 'processor_load', name: 'CPU Load', category: 'load', value: sensorDataArg.load?.processor_load, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'processor_load_1m', name: 'CPU Load 1m', category: 'load', value: sensorDataArg.load?.processor_load_1m, stateClass: 'measurement', entityCategory: 'diagnostic' });
this.pushNumberSensor(sensors, { key: 'processor_load_5m', name: 'CPU Load 5m', category: 'load', value: sensorDataArg.load?.processor_load_5m, stateClass: 'measurement', entityCategory: 'diagnostic' });
this.pushNumberSensor(sensors, { key: 'process_running', name: 'Running', category: 'process', value: sensorDataArg.processcount?.process_running, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'process_total', name: 'Total', category: 'process', value: sensorDataArg.processcount?.process_total, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'process_thread', name: 'Threads', category: 'process', value: sensorDataArg.processcount?.process_thread, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'process_sleeping', name: 'Sleeping', category: 'process', value: sensorDataArg.processcount?.process_sleeping, stateClass: 'measurement' });
for (const [label, environment] of Object.entries(sensorDataArg.sensors || {})) {
for (const [key, value] of Object.entries(environment)) {
const isTemperature = key.startsWith('temperature');
const isFan = key === 'fan_speed';
const name = isTemperature ? `${label} Temperature` : isFan ? `${label} Fan Speed` : `${label} ${this.titleFromKey(key)}`;
this.pushNumberSensor(sensors, {
key: `sensor_${this.slug(label)}_${this.slug(key)}`,
name,
category: isTemperature ? 'temperature' : 'other',
value,
unit: isTemperature ? '°C' : isFan ? 'rpm' : key === 'battery' ? '%' : undefined,
label,
deviceClass: isTemperature ? 'temperature' : key === 'battery' ? 'battery' : undefined,
stateClass: 'measurement',
attributes: { sensorType: key },
});
}
}
this.pushNumberSensor(sensors, { key: 'docker_active', name: 'Containers Active', category: 'container', value: sensorDataArg.docker?.docker_active, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'docker_cpu_use', name: 'Containers CPU Usage', category: 'container', value: sensorDataArg.docker?.docker_cpu_use, unit: '%', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: 'docker_memory_use', name: 'Containers Memory Used', category: 'container', value: sensorDataArg.docker?.docker_memory_use, unit: 'MiB', deviceClass: 'data_size', stateClass: 'measurement' });
for (const [label, container] of Object.entries(sensorDataArg.containers || {})) {
const labelSlug = this.slug(label);
this.pushNumberSensor(sensors, { key: `container_${labelSlug}_cpu_use`, name: `${label} Container CPU Usage`, category: 'container', value: container.container_cpu_use, unit: '%', label, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `container_${labelSlug}_memory_use`, name: `${label} Container Memory Used`, category: 'container', value: container.container_memory_use, unit: 'MiB', label, deviceClass: 'data_size', stateClass: 'measurement' });
}
for (const [label, gpu] of Object.entries(sensorDataArg.gpu || {})) {
const labelSlug = this.slug(label);
this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_temperature`, name: `${label} Temperature`, category: 'gpu', value: gpu.temperature, unit: '°C', label, deviceClass: 'temperature', stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_memory_usage`, name: `${label} Memory Usage`, category: 'gpu', value: gpu.mem, unit: '%', label, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_processor_usage`, name: `${label} Processor Usage`, category: 'gpu', value: gpu.proc, unit: '%', label, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `gpu_${labelSlug}_fan_speed`, name: `${label} Fan Speed`, category: 'gpu', value: gpu.fan_speed, unit: '%', label, stateClass: 'measurement' });
}
for (const [label, raid] of Object.entries(sensorDataArg.raid || {})) {
const labelSlug = this.slug(label);
this.pushNumberSensor(sensors, { key: `raid_${labelSlug}_available`, name: `${label} Available`, category: 'raid', value: raid.available, label, stateClass: 'measurement' });
this.pushNumberSensor(sensors, { key: `raid_${labelSlug}_used`, name: `${label} Used`, category: 'raid', value: raid.used, label, stateClass: 'measurement' });
}
return sensors;
}
public static hostDeviceId(snapshotArg: IGlancesSnapshot): string {
return `${glancesDomain}.host.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || glancesDomain;
}
private static normalizeSnapshot(snapshotArg: IGlancesSnapshot, configArg: IGlancesConfig, sourceArg: TGlancesSnapshotSource): IGlancesSnapshot {
const rawData = snapshotArg.rawData ? this.clone(snapshotArg.rawData) : configArg.rawData || configArg.allData;
const apiVersion = snapshotArg.apiVersion || configArg.apiVersion || configArg.version;
const sensorData = Object.keys(snapshotArg.sensorData || {}).length
? this.clone(snapshotArg.sensorData)
: rawData ? this.haSensorDataFromRawData(rawData, apiVersion || 4) : {};
const host = {
...this.hostInfo(configArg, rawData, apiVersion),
...snapshotArg.host,
};
host.port = host.port || (host.host ? configArg.port || glancesDefaultPort : configArg.port);
host.name = host.name || configArg.name || host.hostname || host.host || 'Glances';
host.id = host.id || configArg.uniqueId || (host.host ? `${host.host}:${host.port || glancesDefaultPort}` : undefined) || host.hostname || host.name;
return {
...snapshotArg,
host,
sensorData,
sensors: snapshotArg.sensors?.length ? this.clone(snapshotArg.sensors) : this.sensorsFromHaSensorData(sensorData),
rawData: rawData ? this.clone(rawData) : undefined,
online: snapshotArg.online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
apiVersion,
};
}
private static hostInfo(configArg: IGlancesConfig, rawDataArg: IGlancesRawData | undefined, apiVersionArg: TGlancesApiVersion | undefined): IGlancesHostInfo {
const system = this.recordValue(rawDataArg?.system);
const host = configArg.host;
const port = configArg.port || (host ? glancesDefaultPort : undefined);
const hostname = this.stringValue(system?.hostname) || this.stringValue(system?.host);
return {
id: configArg.uniqueId || (host ? `${host}:${port}` : undefined) || hostname || configArg.name,
name: configArg.name || hostname || host || 'Glances',
host,
port,
ssl: configArg.ssl,
apiVersion: apiVersionArg,
hostname,
osName: this.stringValue(system?.os_name) || this.stringValue(system?.osName) || this.stringValue(system?.linux_distro),
platform: this.stringValue(system?.platform),
version: this.stringValue(system?.hr_name) || this.stringValue(system?.version),
};
}
private static pushNumberSensor(sensorsArg: IGlancesSensorState[], definitionArg: IGlancesSensorDefinition): void {
const value = this.numberValue(definitionArg.value);
if (value === undefined) {
return;
}
sensorsArg.push(this.sensor({ ...definitionArg, value, numeric: true }));
}
private static sensor(definitionArg: IGlancesSensorDefinition): IGlancesSensorState {
const value = this.sensorValue(definitionArg.value);
return {
key: this.slug(definitionArg.key),
name: definitionArg.name,
category: definitionArg.category,
value,
unit: definitionArg.unit,
label: definitionArg.label,
deviceClass: definitionArg.deviceClass,
stateClass: definitionArg.stateClass,
entityCategory: definitionArg.entityCategory,
available: value !== null && (!definitionArg.numeric || typeof value === 'number'),
attributes: this.cleanAttributes(definitionArg.attributes || {}),
};
}
private static sensorValue(valueArg: unknown): TGlancesSensorValue {
if (typeof valueArg === 'number') {
return Number.isFinite(valueArg) ? valueArg : null;
}
if (typeof valueArg === 'string' || typeof valueArg === 'boolean') {
return valueArg;
}
return null;
}
private static entityId(hostNameArg: string, sensorArg: IGlancesSensorState, usedIdsArg: Map<string, number>): string {
const baseId = `sensor.${this.slug(`${hostNameArg} ${sensorArg.name}`)}`;
const seen = usedIdsArg.get(baseId) || 0;
usedIdsArg.set(baseId, seen + 1);
return seen ? `${baseId}_${seen + 1}` : baseId;
}
private static hostName(snapshotArg: IGlancesSnapshot): string {
return snapshotArg.host.name || snapshotArg.host.hostname || snapshotArg.host.host || 'Glances';
}
private static uniqueBase(snapshotArg: IGlancesSnapshot): string {
return this.slug(snapshotArg.host.id || snapshotArg.host.hostname || snapshotArg.host.host || this.hostName(snapshotArg));
}
private static bytesToMib(valueArg: unknown): number | undefined {
const value = this.numberValue(valueArg);
return value === undefined ? undefined : this.round(value / 1024 ** 2, 1);
}
private static bytesToGib(valueArg: unknown): number | undefined {
const value = this.numberValue(valueArg);
return value === undefined ? undefined : this.round(value / 1024 ** 3, 1);
}
private static round(valueArg: number, digitsArg: number): number {
const factor = 10 ** digitsArg;
return Math.round(valueArg * factor) / factor;
}
private static titleFromKey(valueArg: string): string {
return valueArg.split('_').map((partArg) => partArg ? `${partArg[0].toUpperCase()}${partArg.slice(1)}` : '').join(' ');
}
private static recordValue(valueArg: unknown): Record<string, unknown> | undefined {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
private static arrayRecords(valueArg: unknown): Array<Record<string, unknown>> {
return Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is Record<string, unknown> => Boolean(this.recordValue(itemArg))) : [];
}
private static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (valueArg.toLowerCase() === 'true') {
return true;
}
if (valueArg.toLowerCase() === 'false') {
return false;
}
}
return undefined;
}
private static cleanAttributes<TRecord extends Record<string, unknown>>(attributesArg: TRecord): TRecord {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as TRecord;
}
private static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
+249 -2
View File
@@ -1,4 +1,251 @@
export interface IHomeAssistantGlancesConfig { export const glancesDomain = 'glances';
// TODO: replace with the TypeScript-native config for glances. export const glancesDefaultHost = 'localhost';
export const glancesDefaultPort = 61208;
export const glancesDefaultTimeoutMs = 10000;
export const glancesDefaultScanIntervalMs = 60000;
export type TGlancesApiVersion = 3 | 4;
export type TGlancesSnapshotSource = 'http' | 'manual' | 'snapshot' | 'runtime';
export type TGlancesSensorCategory =
| 'cpu'
| 'memory'
| 'disk'
| 'network'
| 'load'
| 'temperature'
| 'process'
| 'container'
| 'gpu'
| 'raid'
| 'system'
| 'other';
export type TGlancesSensorValue = string | number | boolean | null;
export interface IGlancesConfig {
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
username?: string;
password?: string;
timeoutMs?: number;
name?: string;
uniqueId?: string;
apiVersion?: TGlancesApiVersion;
version?: TGlancesApiVersion;
snapshot?: IGlancesSnapshot;
rawData?: IGlancesRawData;
allData?: IGlancesRawData;
haSensorData?: IGlancesHaSensorData;
sensorData?: IGlancesHaSensorData;
online?: boolean;
}
export interface IHomeAssistantGlancesConfig extends IGlancesConfig {}
export interface IGlancesHostInfo {
id?: string;
name?: string;
host?: string;
port?: number;
ssl?: boolean;
apiVersion?: TGlancesApiVersion;
hostname?: string;
osName?: string;
platform?: string;
version?: string;
}
export interface IGlancesSensorState {
key: string;
name: string;
category: TGlancesSensorCategory;
value: TGlancesSensorValue;
unit?: string;
label?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
available: boolean;
attributes?: Record<string, unknown>;
}
export interface IGlancesSnapshot {
host: IGlancesHostInfo;
sensorData: IGlancesHaSensorData;
sensors: IGlancesSensorState[];
rawData?: IGlancesRawData;
online: boolean;
updatedAt?: string;
source?: TGlancesSnapshotSource;
apiVersion?: TGlancesApiVersion;
error?: string;
}
export interface IGlancesRefreshResult {
success: boolean;
snapshot?: IGlancesSnapshot;
error?: string;
data?: Record<string, unknown>;
}
export interface IGlancesHaSensorData {
fs?: Record<string, IGlancesDiskSensorData>;
diskio?: Record<string, IGlancesDiskIoSensorData>;
mem?: IGlancesMemorySensorData;
memswap?: IGlancesSwapSensorData;
load?: IGlancesLoadSensorData;
processcount?: IGlancesProcessSensorData;
cpu?: IGlancesCpuSensorData;
percpu?: Record<string, IGlancesCpuSensorData>;
sensors?: Record<string, IGlancesEnvironmentSensorData>;
network?: Record<string, IGlancesNetworkSensorData>;
docker?: IGlancesDockerSensorData;
containers?: Record<string, IGlancesContainerSensorData>;
raid?: Record<string, Record<string, unknown>>;
uptime?: unknown;
gpu?: Record<string, IGlancesGpuSensorData>;
computed?: Record<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
export interface IGlancesDiskSensorData {
disk_use?: number;
disk_use_percent?: number;
disk_size?: number;
disk_free?: number;
[key: string]: unknown;
}
export interface IGlancesDiskIoSensorData {
read?: number;
write?: number;
[key: string]: unknown;
}
export interface IGlancesMemorySensorData {
memory_use_percent?: number;
memory_use?: number;
memory_free?: number;
[key: string]: unknown;
}
export interface IGlancesSwapSensorData {
swap_use_percent?: number;
swap_use?: number;
swap_free?: number;
[key: string]: unknown;
}
export interface IGlancesLoadSensorData {
processor_load?: number;
processor_load_1m?: number;
processor_load_5m?: number;
[key: string]: unknown;
}
export interface IGlancesProcessSensorData {
process_running?: number;
process_total?: number;
process_thread?: number;
process_sleeping?: number;
[key: string]: unknown;
}
export interface IGlancesCpuSensorData {
cpu_use_percent?: number;
[key: string]: unknown;
}
export interface IGlancesEnvironmentSensorData {
temperature_core?: number;
temperature_hdd?: number;
fan_speed?: number;
battery?: number;
[key: string]: unknown;
}
export interface IGlancesNetworkSensorData {
is_up?: boolean;
rx?: number;
tx?: number;
speed?: number;
[key: string]: unknown;
}
export interface IGlancesDockerSensorData {
docker_active?: number;
docker_cpu_use?: number;
docker_memory_use?: number;
[key: string]: unknown;
}
export interface IGlancesContainerSensorData {
container_cpu_use?: number;
container_memory_use?: number;
[key: string]: unknown;
}
export interface IGlancesGpuSensorData {
temperature?: number;
mem?: number;
proc?: number;
fan_speed?: number;
[key: string]: unknown;
}
export interface IGlancesRawData {
system?: Record<string, unknown>;
fs?: Array<Record<string, unknown>>;
diskio?: Array<Record<string, unknown>>;
sensors?: Array<Record<string, unknown>>;
mem?: Record<string, unknown>;
memswap?: Record<string, unknown>;
load?: Record<string, unknown>;
processcount?: Record<string, unknown>;
quicklook?: Record<string, unknown>;
percpu?: Array<Record<string, unknown>>;
network?: Array<Record<string, unknown>>;
dockers?: Record<string, unknown>;
containers?: unknown;
raid?: Record<string, Record<string, unknown>>;
uptime?: unknown;
gpu?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
export interface IGlancesManualEntry {
host?: string;
port?: number;
url?: string;
ssl?: boolean;
verifySsl?: boolean;
username?: string;
password?: string;
apiVersion?: TGlancesApiVersion;
version?: TGlancesApiVersion;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
snapshot?: IGlancesSnapshot;
rawData?: IGlancesRawData;
allData?: IGlancesRawData;
haSensorData?: IGlancesHaSensorData;
sensorData?: IGlancesHaSensorData;
metadata?: Record<string, unknown>;
}
export interface IGlancesHttpCandidateRecord {
url?: string;
location?: string;
host?: string;
port?: number;
ssl?: boolean;
name?: string;
manufacturer?: string;
model?: string;
headers?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './glances.classes.integration.js'; export * from './glances.classes.integration.js';
export * from './glances.classes.client.js';
export * from './glances.classes.configflow.js';
export * from './glances.discovery.js';
export * from './glances.mapper.js';
export * from './glances.types.js'; export * from './glances.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+735
View File
@@ -0,0 +1,735 @@
import * as plugins from '../../plugins.js';
import type {
IHeosCommandRequest,
IHeosConfig,
IHeosGroup,
IHeosHost,
IHeosMediaItem,
IHeosMessage,
IHeosMusicSource,
IHeosNowPlayingMedia,
IHeosPlayer,
IHeosSnapshot,
THeosPlayState,
THeosRepeatType,
} from './heos.types.js';
import { heosDefaultPort } from './heos.types.js';
const defaultTimeoutMs = 15000;
const heosLineSeparator = '\r\n';
const musicSourceAuxInput = 1027;
const musicSourceFavorites = 1028;
const commandNames = {
checkAccount: 'system/check_account',
signIn: 'system/sign_in',
getPlayers: 'player/get_players',
getPlayState: 'player/get_play_state',
setPlayState: 'player/set_play_state',
getNowPlaying: 'player/get_now_playing_media',
getVolume: 'player/get_volume',
setVolume: 'player/set_volume',
volumeUp: 'player/volume_up',
volumeDown: 'player/volume_down',
getMute: 'player/get_mute',
setMute: 'player/set_mute',
getPlayMode: 'player/get_play_mode',
playNext: 'player/play_next',
playPrevious: 'player/play_previous',
getGroups: 'group/get_groups',
getGroupVolume: 'group/get_volume',
setGroupVolume: 'group/set_volume',
groupVolumeUp: 'group/volume_up',
groupVolumeDown: 'group/volume_down',
getGroupMute: 'group/get_mute',
setGroup: 'group/set_group',
getMusicSources: 'browse/get_music_sources',
browse: 'browse/browse',
playPreset: 'browse/play_preset',
playInput: 'browse/play_input',
playStream: 'browse/play_stream',
} as const;
export class HeosCommandError extends Error {
constructor(public readonly command: string, messageArg: string, public readonly response?: IHeosMessage) {
super(`HEOS command ${command} failed: ${messageArg}`);
this.name = 'HeosCommandError';
}
}
export class HeosClient {
private currentSnapshot?: IHeosSnapshot;
constructor(private readonly config: IHeosConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot)) : undefined;
}
public async getSnapshot(): Promise<IHeosSnapshot> {
if (this.currentSnapshot) {
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot));
}
if (!this.config.host) {
return this.normalizeSnapshot({
system: { host: undefined, currentHost: undefined, isSignedIn: false },
players: [],
groups: [],
musicSources: [],
favorites: {},
inputSources: [],
lastUpdated: new Date().toISOString(),
});
}
const signedInUsername = await this.signInOrCheckAccount().catch(() => undefined);
const players = await this.getPlayers();
const [groups, musicSources, inputSources, favorites] = await Promise.all([
this.getGroups().catch(() => []),
this.getMusicSources().catch(() => []),
this.getInputSources().catch(() => []),
signedInUsername ? this.getFavorites().catch(() => ({})) : Promise.resolve({}),
]);
return this.normalizeSnapshot({
system: {
host: this.config.host,
currentHost: this.config.host,
signedInUsername,
isSignedIn: Boolean(signedInUsername),
hosts: players.map((playerArg) => this.playerToHost(playerArg)),
preferredHosts: players.map((playerArg) => this.playerToHost(playerArg)).filter((hostArg) => hostArg.preferredHost),
},
players,
groups,
musicSources,
inputSources,
favorites,
lastUpdated: new Date().toISOString(),
});
}
public async validateConnection(): Promise<IHeosSnapshot> {
const players = await this.getPlayers();
return this.normalizeSnapshot({
system: {
host: this.config.host,
currentHost: this.config.host,
hosts: players.map((playerArg) => this.playerToHost(playerArg)),
},
players,
lastUpdated: new Date().toISOString(),
});
}
public async execute(requestArg: IHeosCommandRequest): Promise<unknown> {
if (requestArg.command === 'play') {
return this.setPlayState(this.requiredPlayerId(requestArg), 'play');
}
if (requestArg.command === 'pause') {
return this.setPlayState(this.requiredPlayerId(requestArg), 'pause');
}
if (requestArg.command === 'stop') {
return this.setPlayState(this.requiredPlayerId(requestArg), 'stop');
}
if (requestArg.command === 'previous_track') {
return this.command(commandNames.playPrevious, { pid: this.requiredPlayerId(requestArg) });
}
if (requestArg.command === 'next_track') {
return this.command(commandNames.playNext, { pid: this.requiredPlayerId(requestArg) });
}
if (requestArg.command === 'volume_up') {
return this.command(commandNames.volumeUp, { pid: this.requiredPlayerId(requestArg), step: this.step(requestArg.step) });
}
if (requestArg.command === 'volume_down') {
return this.command(commandNames.volumeDown, { pid: this.requiredPlayerId(requestArg), step: this.step(requestArg.step) });
}
if (requestArg.command === 'set_volume') {
return this.command(commandNames.setVolume, { pid: this.requiredPlayerId(requestArg), level: this.volumePercent(requestArg) });
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('HEOS mute command requires muted.');
}
return this.command(commandNames.setMute, { pid: this.requiredPlayerId(requestArg), state: requestArg.muted ? 'on' : 'off' });
}
if (requestArg.command === 'select_source') {
return this.selectSource(requestArg);
}
if (requestArg.command === 'play_preset') {
return this.command(commandNames.playPreset, { pid: this.requiredPlayerId(requestArg), preset: this.requiredNumber(requestArg.preset, 'HEOS play_preset requires preset.') });
}
if (requestArg.command === 'play_input') {
if (!requestArg.source) {
throw new Error('HEOS play_input requires source.');
}
return this.command(commandNames.playInput, { pid: this.requiredPlayerId(requestArg), input: requestArg.source });
}
if (requestArg.command === 'play_media') {
if (!requestArg.url && !requestArg.mediaId) {
throw new Error('HEOS play_media requires url or mediaId.');
}
return this.command(commandNames.playStream, { pid: this.requiredPlayerId(requestArg), url: requestArg.url || requestArg.mediaId || '' });
}
if (requestArg.command === 'set_group' || requestArg.command === 'join') {
if (!requestArg.playerIds?.length) {
throw new Error('HEOS set_group requires playerIds.');
}
return this.setGroup(requestArg.playerIds);
}
if (requestArg.command === 'unjoin') {
return this.unjoinPlayer(this.requiredPlayerId(requestArg));
}
if (requestArg.command === 'group_volume_set') {
return this.command(commandNames.setGroupVolume, { gid: this.requiredGroupId(requestArg), level: this.volumePercent(requestArg) });
}
if (requestArg.command === 'group_volume_up') {
return this.command(commandNames.groupVolumeUp, { gid: this.requiredGroupId(requestArg), step: this.step(requestArg.step) });
}
if (requestArg.command === 'group_volume_down') {
return this.command(commandNames.groupVolumeDown, { gid: this.requiredGroupId(requestArg), step: this.step(requestArg.step) });
}
throw new Error(`Unsupported HEOS command: ${requestArg.command}`);
}
public async destroy(): Promise<void> {}
private async getPlayers(): Promise<IHeosPlayer[]> {
const response = await this.command(commandNames.getPlayers);
const players = this.payloadArray(response).map((itemArg) => this.playerFromData(itemArg));
return Promise.all(players.map((playerArg) => this.refreshPlayer(playerArg)));
}
private async refreshPlayer(playerArg: IHeosPlayer): Promise<IHeosPlayer> {
const [state, nowPlayingMedia, volume, muted, playMode] = await Promise.all([
this.getPlayState(playerArg.playerId).catch(() => undefined),
this.getNowPlaying(playerArg.playerId).catch(() => undefined),
this.getVolume(playerArg.playerId).catch(() => undefined),
this.getMute(playerArg.playerId).catch(() => undefined),
this.getPlayMode(playerArg.playerId).catch(() => undefined),
]);
return {
...playerArg,
state: state || playerArg.state,
volume: volume ?? playerArg.volume,
muted: muted ?? playerArg.muted,
repeat: playMode?.repeat || playerArg.repeat,
shuffle: playMode?.shuffle ?? playerArg.shuffle,
nowPlayingMedia: nowPlayingMedia || playerArg.nowPlayingMedia,
available: true,
};
}
private async getGroups(): Promise<IHeosGroup[]> {
const response = await this.command(commandNames.getGroups);
const groups = this.payloadArray(response).map((itemArg) => this.groupFromData(itemArg));
return Promise.all(groups.map(async (groupArg) => ({
...groupArg,
volume: await this.getGroupVolume(groupArg.groupId).catch(() => groupArg.volume),
muted: await this.getGroupMute(groupArg.groupId).catch(() => groupArg.muted),
})));
}
private async getMusicSources(): Promise<IHeosMusicSource[]> {
const response = await this.command(commandNames.getMusicSources);
return this.payloadArray(response).map((itemArg) => this.musicSourceFromData(itemArg));
}
private async getInputSources(): Promise<IHeosMediaItem[]> {
const root = await this.browse(musicSourceAuxInput);
const inputs: IHeosMediaItem[] = [];
for (const item of root) {
if (!item.containerId && !item.browsable) {
continue;
}
const children = await this.browse(item.sourceId, item.containerId).catch(() => []);
inputs.push(...children);
}
return inputs;
}
private async getFavorites(): Promise<Record<number, IHeosMediaItem>> {
const favorites = await this.browse(musicSourceFavorites);
const result: Record<number, IHeosMediaItem> = {};
favorites.forEach((favoriteArg, indexArg) => {
result[indexArg + 1] = favoriteArg;
});
return result;
}
private async browse(sourceIdArg: number, containerIdArg?: string): Promise<IHeosMediaItem[]> {
const response = await this.command(commandNames.browse, containerIdArg ? { sid: sourceIdArg, cid: containerIdArg } : { sid: sourceIdArg });
return this.payloadArray(response).map((itemArg) => this.mediaItemFromData(itemArg, sourceIdArg, containerIdArg));
}
private async signInOrCheckAccount(): Promise<string | undefined> {
if (this.config.username && this.config.password) {
const response = await this.command(commandNames.signIn, { un: this.config.username, pw: this.config.password });
return response.message.un;
}
const response = await this.command(commandNames.checkAccount);
return response.message.un;
}
private async getPlayState(playerIdArg: number): Promise<THeosPlayState> {
const response = await this.command(commandNames.getPlayState, { pid: playerIdArg });
return response.message.state || 'unknown';
}
private async setPlayState(playerIdArg: number, stateArg: 'play' | 'pause' | 'stop'): Promise<IHeosMessage> {
return this.command(commandNames.setPlayState, { pid: playerIdArg, state: stateArg });
}
private async getNowPlaying(playerIdArg: number): Promise<IHeosNowPlayingMedia> {
const response = await this.command(commandNames.getNowPlaying, { pid: playerIdArg });
const payload = this.payloadRecord(response);
return this.nowPlayingFromData({ ...payload, ...response.message });
}
private async getVolume(playerIdArg: number): Promise<number | undefined> {
const response = await this.command(commandNames.getVolume, { pid: playerIdArg });
return this.numberValue(response.message.level);
}
private async getMute(playerIdArg: number): Promise<boolean | undefined> {
const response = await this.command(commandNames.getMute, { pid: playerIdArg });
return response.message.state === 'on';
}
private async getPlayMode(playerIdArg: number): Promise<{ repeat?: THeosRepeatType; shuffle?: boolean }> {
const response = await this.command(commandNames.getPlayMode, { pid: playerIdArg });
return { repeat: response.message.repeat, shuffle: response.message.shuffle === 'on' };
}
private async getGroupVolume(groupIdArg: number): Promise<number | undefined> {
const response = await this.command(commandNames.getGroupVolume, { gid: groupIdArg });
return this.numberValue(response.message.level);
}
private async getGroupMute(groupIdArg: number): Promise<boolean | undefined> {
const response = await this.command(commandNames.getGroupMute, { gid: groupIdArg });
return response.message.state === 'on';
}
private async selectSource(requestArg: IHeosCommandRequest): Promise<IHeosMessage> {
if (!requestArg.source) {
throw new Error('HEOS select_source requires source.');
}
const snapshot = await this.getSnapshot();
const favoriteEntry = Object.entries(snapshot.favorites || {}).find(([, favoriteArg]) => favoriteArg.name === requestArg.source);
if (favoriteEntry) {
return this.command(commandNames.playPreset, { pid: this.requiredPlayerId(requestArg), preset: Number(favoriteEntry[0]) });
}
const inputSource = (snapshot.inputSources || []).find((sourceArg) => sourceArg.name === requestArg.source || sourceArg.mediaId === requestArg.source);
if (inputSource) {
const params: Record<string, string | number | boolean> = { pid: this.requiredPlayerId(requestArg), input: inputSource.mediaId || inputSource.name };
if (inputSource.sourceId && inputSource.sourceId !== musicSourceAuxInput) {
params.spid = inputSource.sourceId;
}
return this.command(commandNames.playInput, params);
}
throw new Error(`Unknown HEOS source: ${requestArg.source}`);
}
private async setGroup(playerIdsArg: number[]): Promise<IHeosMessage> {
const uniquePlayerIds = [...new Set(playerIdsArg)];
if (!uniquePlayerIds.length) {
throw new Error('HEOS set_group requires at least one player id.');
}
return this.command(commandNames.setGroup, { pid: uniquePlayerIds.join(',') });
}
private async unjoinPlayer(playerIdArg: number): Promise<IHeosMessage | undefined> {
const snapshot = await this.getSnapshot();
const group = (snapshot.groups || []).find((groupArg) => groupArg.leadPlayerId === playerIdArg || groupArg.memberPlayerIds.includes(playerIdArg));
if (!group) {
return undefined;
}
if (group.leadPlayerId === playerIdArg) {
return this.setGroup([playerIdArg]);
}
return this.setGroup([group.leadPlayerId, ...group.memberPlayerIds.filter((memberArg) => memberArg !== playerIdArg)]);
}
private async command(commandArg: string, parametersArg: Record<string, string | number | boolean> = {}): Promise<IHeosMessage> {
const uri = this.commandUri(commandArg, parametersArg);
if (this.config.commandExecutor) {
const result = await this.config.commandExecutor.execute({
command: commandArg,
parameters: parametersArg,
uri,
host: this.config.host,
port: this.config.port || heosDefaultPort,
});
return this.executorResultToMessage(commandArg, result);
}
return this.requestTcp(commandArg, parametersArg, uri);
}
private async requestTcp(commandArg: string, parametersArg: Record<string, string | number | boolean>, uriArg: string): Promise<IHeosMessage> {
const host = this.config.host;
if (!host) {
throw new Error('HEOS command transport requires config.host or commandExecutor.');
}
const port = this.config.port || heosDefaultPort;
const timeoutMs = this.config.timeoutMs || defaultTimeoutMs;
return new Promise<IHeosMessage>((resolve, reject) => {
let buffer = '';
let settled = false;
const socket = plugins.net.createConnection({ host, port });
const finish = (errorArg?: Error, messageArg?: IHeosMessage) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
if (errorArg) {
reject(errorArg);
return;
}
resolve(messageArg as IHeosMessage);
};
const handleLine = (lineArg: string) => {
if (!lineArg.trim()) {
return;
}
let message: IHeosMessage;
try {
message = this.parseRawMessage(lineArg);
} catch (errorArg) {
finish(errorArg instanceof Error ? errorArg : new Error(String(errorArg)));
return;
}
if (this.isUnderProcess(message) || message.command.startsWith('event/')) {
return;
}
if (message.command !== commandArg) {
return;
}
if (!message.result) {
finish(new HeosCommandError(commandArg, this.commandErrorText(message), message));
return;
}
finish(undefined, message);
};
socket.setEncoding('utf8');
socket.setTimeout(timeoutMs, () => finish(new Error(`HEOS TCP command ${commandArg} timed out after ${timeoutMs}ms.`)));
socket.on('connect', () => socket.write(`${uriArg}${heosLineSeparator}`));
socket.on('error', (errorArg) => finish(errorArg));
socket.on('close', () => finish(new Error(`HEOS TCP connection closed before ${commandArg} completed.`)));
socket.on('data', (chunkArg) => {
buffer += chunkArg;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || '';
for (const line of lines) {
handleLine(line);
}
});
void parametersArg;
});
}
private playerFromData(dataArg: Record<string, unknown>): IHeosPlayer {
const version = this.stringValue(dataArg.version);
const playerId = this.requiredParsedNumber(dataArg.pid, 'HEOS player payload missing pid.');
return {
name: this.stringValue(dataArg.name) || `HEOS ${playerId}`,
playerId,
model: this.stringValue(dataArg.model) || 'HEOS Player',
serial: this.stringValue(dataArg.serial),
version,
supportedVersion: this.isSupportedVersion(version),
ipAddress: this.stringValue(dataArg.ip),
network: this.stringValue(dataArg.network) || 'unknown',
lineOut: this.stringValue(dataArg.lineout) || this.numberValue(dataArg.lineout),
control: this.stringValue(dataArg.control) || this.numberValue(dataArg.control),
groupId: this.numberValue(dataArg.gid),
available: true,
};
}
private groupFromData(dataArg: Record<string, unknown>): IHeosGroup {
const players = Array.isArray(dataArg.players) ? dataArg.players.filter((itemArg): itemArg is Record<string, unknown> => this.isRecord(itemArg)) : [];
const leader = players.find((playerArg) => this.stringValue(playerArg.role) === 'leader');
const members = players.filter((playerArg) => this.stringValue(playerArg.role) !== 'leader').map((playerArg) => this.numberValue(playerArg.pid)).filter((valueArg): valueArg is number => typeof valueArg === 'number');
const groupId = this.requiredParsedNumber(dataArg.gid, 'HEOS group payload missing gid.');
return {
name: this.stringValue(dataArg.name) || `HEOS Group ${groupId}`,
groupId,
leadPlayerId: this.numberValue(leader?.pid) || groupId,
memberPlayerIds: members,
};
}
private musicSourceFromData(dataArg: Record<string, unknown>): IHeosMusicSource {
return {
sourceId: this.requiredParsedNumber(dataArg.sid, 'HEOS music source payload missing sid.'),
name: this.stringValue(dataArg.name) || 'HEOS Source',
type: this.stringValue(dataArg.type) || 'music_service',
imageUrl: this.stringValue(dataArg.image_url),
available: this.stringValue(dataArg.available) === 'true',
serviceUsername: this.stringValue(dataArg.service_username),
};
}
private mediaItemFromData(dataArg: Record<string, unknown>, sourceIdArg?: number, containerIdArg?: string): IHeosMediaItem {
const sourceId = this.numberValue(dataArg.sid) || sourceIdArg;
if (!sourceId) {
throw new Error('HEOS media item payload missing sid.');
}
return {
sourceId,
name: this.decodeName(this.stringValue(dataArg.name) || 'HEOS Media'),
type: this.stringValue(dataArg.type) || 'container',
imageUrl: this.stringValue(dataArg.image_url),
playable: this.stringValue(dataArg.playable) === 'yes',
browsable: dataArg.sid !== undefined || this.stringValue(dataArg.container) === 'yes',
containerId: this.stringValue(dataArg.cid) || containerIdArg,
mediaId: this.stringValue(dataArg.mid),
artist: this.stringValue(dataArg.artist),
album: this.stringValue(dataArg.album),
albumId: this.stringValue(dataArg.album_id),
};
}
private nowPlayingFromData(dataArg: Record<string, unknown>): IHeosNowPlayingMedia {
const sourceId = this.numberValue(dataArg.sid);
const type = this.stringValue(dataArg.type);
return {
type,
song: this.stringValue(dataArg.song),
station: this.stringValue(dataArg.station),
album: this.stringValue(dataArg.album),
artist: this.stringValue(dataArg.artist),
imageUrl: this.stringValue(dataArg.image_url),
albumId: this.stringValue(dataArg.album_id),
mediaId: this.stringValue(dataArg.mid),
queueId: this.numberValue(dataArg.qid),
sourceId,
currentPosition: this.numberValue(dataArg.cur_pos),
duration: this.numberValue(dataArg.duration),
supportedControls: this.supportedControls(sourceId, type),
};
}
private playerToHost(playerArg: IHeosPlayer): IHeosHost {
return {
name: playerArg.name,
model: playerArg.model,
serial: playerArg.serial,
version: playerArg.version,
ipAddress: playerArg.ipAddress,
network: playerArg.network,
supportedVersion: playerArg.supportedVersion,
preferredHost: playerArg.network === 'wired' && playerArg.supportedVersion !== false && Boolean(playerArg.ipAddress),
};
}
private supportedControls(sourceIdArg: number | undefined, typeArg: string | undefined): string[] {
if (sourceIdArg === undefined) {
return [];
}
if (sourceIdArg === musicSourceAuxInput) {
return ['play', 'stop'];
}
if (typeArg === 'station') {
return ['play', 'pause', 'stop', 'play_next'];
}
return ['play', 'pause', 'stop', 'play_next', 'play_previous'];
}
private normalizeSnapshot(snapshotArg: IHeosSnapshot): IHeosSnapshot {
const sourceList = snapshotArg.sourceList || [
...Object.values(snapshotArg.favorites || {}).map((favoriteArg) => favoriteArg.name),
...(snapshotArg.inputSources || []).map((sourceArg) => sourceArg.name),
].filter(Boolean);
return {
...snapshotArg,
system: {
...snapshotArg.system,
isSignedIn: snapshotArg.system.isSignedIn ?? Boolean(snapshotArg.system.signedInUsername),
},
players: snapshotArg.players || [],
groups: snapshotArg.groups || [],
musicSources: snapshotArg.musicSources || [],
favorites: snapshotArg.favorites || {},
inputSources: snapshotArg.inputSources || [],
sourceList: [...new Set(sourceList)],
lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
};
}
private commandUri(commandArg: string, parametersArg: Record<string, string | number | boolean>): string {
const params = this.encodeQuery(parametersArg);
return params ? `heos://${commandArg}?${params}` : `heos://${commandArg}`;
}
private encodeQuery(parametersArg: Record<string, string | number | boolean>): string {
const pairs: string[] = [];
for (const key of Object.keys(parametersArg).sort()) {
const value = parametersArg[key];
if (key === 'url') {
pairs.push(`${key}=${value}`);
} else {
pairs.unshift(`${key}=${this.quoteCliValue(value)}`);
}
}
return pairs.join('&');
}
private quoteCliValue(valueArg: string | number | boolean): string {
return String(valueArg).replace(/%/g, '%25').replace(/&/g, '%26').replace(/=/g, '%3D');
}
private parseRawMessage(rawArg: string): IHeosMessage {
const container = JSON.parse(rawArg) as { heos?: { command?: string; result?: string; message?: string }; payload?: unknown; options?: unknown };
if (!container.heos?.command) {
throw new Error(`Invalid HEOS response: ${rawArg}`);
}
return {
command: container.heos.command,
result: (container.heos.result || 'success') === 'success',
message: this.parseMessage(container.heos.message || ''),
payload: container.payload,
options: container.options,
raw: rawArg,
};
}
private parseMessage(valueArg: string): Record<string, string> {
const result: Record<string, string> = {};
const params = new URLSearchParams(valueArg);
params.forEach((value, key) => {
result[key] = value;
});
if (!Object.keys(result).length && valueArg) {
result[valueArg] = '';
}
return result;
}
private executorResultToMessage(commandArg: string, resultArg: unknown): IHeosMessage {
if (this.isHeosMessage(resultArg)) {
if (!resultArg.result) {
throw new HeosCommandError(commandArg, this.commandErrorText(resultArg), resultArg);
}
return resultArg;
}
return { command: commandArg, result: true, message: {}, payload: resultArg };
}
private payloadArray(responseArg: IHeosMessage): Record<string, unknown>[] {
return Array.isArray(responseArg.payload) ? responseArg.payload.filter((itemArg): itemArg is Record<string, unknown> => this.isRecord(itemArg)) : [];
}
private payloadRecord(responseArg: IHeosMessage): Record<string, unknown> {
return this.isRecord(responseArg.payload) ? responseArg.payload : {};
}
private requiredPlayerId(requestArg: IHeosCommandRequest): number {
if (typeof requestArg.playerId === 'number') {
return requestArg.playerId;
}
throw new Error('HEOS command requires playerId.');
}
private requiredGroupId(requestArg: IHeosCommandRequest): number {
if (typeof requestArg.groupId === 'number') {
return requestArg.groupId;
}
throw new Error('HEOS group command requires groupId.');
}
private volumePercent(requestArg: IHeosCommandRequest): number {
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('HEOS volume command requires volumeLevel or volume.');
}
return Math.max(0, Math.min(100, Math.round(value)));
}
private step(valueArg: number | undefined): number {
const value = typeof valueArg === 'number' ? valueArg : 5;
return Math.max(1, Math.min(10, Math.round(value)));
}
private requiredNumber(valueArg: unknown, errorArg: string): number {
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
throw new Error(errorArg);
}
return valueArg;
}
private requiredParsedNumber(valueArg: unknown, errorArg: string): number {
const value = this.numberValue(valueArg);
if (typeof value !== 'number') {
throw new Error(errorArg);
}
return value;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
private isHeosMessage(valueArg: unknown): valueArg is IHeosMessage {
return this.isRecord(valueArg) && typeof valueArg.command === 'string' && typeof valueArg.result === 'boolean' && this.isRecord(valueArg.message);
}
private isUnderProcess(messageArg: IHeosMessage): boolean {
return Object.keys(messageArg.message).some((keyArg) => keyArg.includes('command under process'));
}
private commandErrorText(messageArg: IHeosMessage): string {
return messageArg.message.text || messageArg.message.error || messageArg.message.errno || 'Command failed';
}
private decodeName(valueArg: string): string {
try {
return decodeURIComponent(valueArg);
} catch {
return valueArg;
}
}
private isSupportedVersion(versionArg: string | undefined): boolean | undefined {
if (!versionArg) {
return undefined;
}
const versionParts = versionArg.split('.').map((partArg) => Number(partArg));
const targetParts = [3, 34, 0];
for (let index = 0; index < targetParts.length; index++) {
const versionPart = versionParts[index] || 0;
if (versionPart > targetParts[index]) {
return true;
}
if (versionPart < targetParts[index]) {
return false;
}
}
return true;
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
@@ -0,0 +1,58 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IHeosConfig } from './heos.types.js';
import { heosDefaultPort } from './heos.types.js';
export class HeosConfigFlow implements IConfigFlow<IHeosConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHeosConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Denon HEOS',
description: candidateArg.source === 'manual'
? 'Configure a local HEOS host.'
: 'Confirm or adjust the discovered local HEOS host.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'username', label: 'HEOS Account Username', type: 'text' },
{ name: 'password', label: 'HEOS Account Password', type: 'password' },
],
submit: async (valuesArg) => {
const host = stringValue(valuesArg.host) || candidateArg.host;
if (!host) {
return { kind: 'error', title: 'HEOS host required', error: 'HEOS setup requires a host.' };
}
const port = numberValue(valuesArg.port) || candidateArg.port || heosDefaultPort;
return {
kind: 'done',
title: 'HEOS configured',
config: {
host,
port,
name: candidateArg.name,
manufacturer: candidateArg.manufacturer,
model: candidateArg.model,
serialNumber: candidateArg.serialNumber,
username: stringValue(valuesArg.username),
password: stringValue(valuesArg.password),
},
};
},
};
}
}
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
+252 -24
View File
@@ -1,27 +1,255 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { HeosClient } from './heos.classes.client.js';
import { HeosConfigFlow } from './heos.classes.configflow.js';
import { createHeosDiscoveryDescriptor } from './heos.discovery.js';
import { HeosMapper } from './heos.mapper.js';
import type { IHeosCommandRequest, IHeosConfig, THeosCommand } from './heos.types.js';
export class HomeAssistantHeosIntegration extends DescriptorOnlyIntegration { export class HeosIntegration extends BaseIntegration<IHeosConfig> {
constructor() { public readonly domain = 'heos';
super({ public readonly displayName = 'Denon HEOS';
domain: "heos", public readonly status = 'control-runtime' as const;
displayName: "Denon HEOS", public readonly discoveryDescriptor = createHeosDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new HeosConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/heos", upstreamPath: 'homeassistant/components/heos',
"upstreamDomain": "heos", upstreamDomain: 'heos',
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "platinum", qualityScale: 'platinum',
"requirements": [ requirements: ['pyheos==1.0.6'],
"pyheos==1.0.6" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@andrewsayre'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/heos',
"@andrewsayre" };
]
}, public async setup(configArg: IHeosConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}); void contextArg;
return new HeosRuntime(new HeosClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantHeosIntegration extends HeosIntegration {}
class HeosRuntime implements IIntegrationRuntime {
public domain = 'heos';
constructor(private readonly client: HeosClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return HeosMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return HeosMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'heos') {
return await this.callHeosService(requestArg);
}
return { success: false, error: `Unsupported HEOS service domain: ${requestArg.domain}` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
const playerIds = await this.joinPlayerIdsFromRequest(requestArg);
return { success: true, data: await this.client.execute({ command: 'set_group', playerIds }) };
}
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return { success: true, data: await this.client.execute({ command: 'unjoin', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'group_volume_set') {
return { success: true, data: await this.client.execute({ command: 'group_volume_set', groupId: await this.groupIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level') }) };
}
if (requestArg.service === 'group_volume_up') {
return { success: true, data: await this.client.execute({ command: 'group_volume_up', groupId: await this.groupIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
}
if (requestArg.service === 'group_volume_down') {
return { success: true, data: await this.client.execute({ command: 'group_volume_down', groupId: await this.groupIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
}
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported HEOS media_player service: ${requestArg.service}` };
}
const result = await this.client.execute(await this.commandRequest(command, requestArg));
return { success: true, data: result };
}
private async callHeosService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'group_volume_set' || requestArg.service === 'group_volume_up' || requestArg.service === 'group_volume_down') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported HEOS service: ${requestArg.service}` };
}
private commandFromMediaService(serviceArg: string): THeosCommand | undefined {
if (serviceArg === 'media_play' || serviceArg === 'play') {
return 'play';
}
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
return 'pause';
}
if (serviceArg === 'media_stop' || serviceArg === 'stop') {
return 'stop';
}
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track') {
return 'previous_track';
}
if (serviceArg === 'media_next_track' || serviceArg === 'next_track') {
return 'next_track';
}
if (serviceArg === 'volume_up') {
return 'volume_up';
}
if (serviceArg === 'volume_down') {
return 'volume_down';
}
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
return 'set_volume';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'select_source' || serviceArg === 'select_input') {
return 'select_source';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
return undefined;
}
private async commandRequest(commandArg: THeosCommand, requestArg: IServiceCallRequest): Promise<IHeosCommandRequest> {
const base: IHeosCommandRequest = {
command: commandArg,
playerId: await this.playerIdFromRequest(requestArg),
step: this.numberData(requestArg, 'step'),
};
if (commandArg === 'set_volume') {
base.volumeLevel = this.numberData(requestArg, 'volume_level');
base.volume = this.numberData(requestArg, 'volume');
}
if (commandArg === 'mute') {
base.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted');
}
if (commandArg === 'select_source') {
base.source = this.stringData(requestArg, 'source') || this.stringData(requestArg, 'input');
}
if (commandArg === 'play_media') {
base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri');
base.url = this.stringData(requestArg, 'url');
base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
}
return base;
}
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<number> {
const direct = this.numberData(requestArg, 'player_id') ?? this.numberData(requestArg, 'playerId') ?? this.numberData(requestArg, 'pid');
if (typeof direct === 'number') {
return direct;
}
const snapshot = await this.client.getSnapshot();
if (requestArg.target.entityId) {
const entityPlayerId = HeosMapper.entityPlayerId(snapshot, requestArg.target.entityId);
if (typeof entityPlayerId === 'number') {
return entityPlayerId;
}
}
if (requestArg.target.deviceId) {
const player = snapshot.players.find((playerArg) => HeosMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
if (player) {
return player.playerId;
}
}
if (snapshot.players.length === 1) {
return snapshot.players[0].playerId;
}
throw new Error('HEOS service call requires data.player_id or a target HEOS media_player entity.');
}
private async groupIdFromRequest(requestArg: IServiceCallRequest): Promise<number> {
const direct = this.numberData(requestArg, 'group_id') ?? this.numberData(requestArg, 'groupId') ?? this.numberData(requestArg, 'gid');
if (typeof direct === 'number') {
return direct;
}
const playerId = await this.playerIdFromRequest(requestArg);
const snapshot = await this.client.getSnapshot();
const player = snapshot.players.find((playerArg) => playerArg.playerId === playerId);
if (typeof player?.groupId === 'number') {
return player.groupId;
}
throw new Error('HEOS group volume service requires a grouped player or data.group_id.');
}
private async joinPlayerIdsFromRequest(requestArg: IServiceCallRequest): Promise<number[]> {
const leaderId = await this.playerIdFromRequest(requestArg);
const directIds = this.numberArrayData(requestArg, 'player_ids') || this.numberArrayData(requestArg, 'playerIds');
if (directIds?.length) {
return [leaderId, ...directIds.filter((playerIdArg) => playerIdArg !== leaderId)];
}
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers');
if (!members?.length) {
throw new Error('HEOS join service requires data.group_members or data.player_ids.');
}
const snapshot = await this.client.getSnapshot();
const memberIds = members.map((entityIdArg) => HeosMapper.entityPlayerId(snapshot, entityIdArg)).filter((playerIdArg): playerIdArg is number => typeof playerIdArg === 'number');
if (memberIds.length !== members.length) {
throw new Error('HEOS join service could not resolve all group member entity IDs.');
}
return [leaderId, ...memberIds.filter((playerIdArg) => playerIdArg !== leaderId)];
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string') {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
}
private numberArrayData(requestArg: IServiceCallRequest, keyArg: string): number[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'number' && Number.isFinite(itemArg)) ? value : undefined;
} }
} }
+221
View File
@@ -0,0 +1,221 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IHeosManualEntry, IHeosMdnsRecord, IHeosSsdpRecord } from './heos.types.js';
import { heosDefaultPort } from './heos.types.js';
const heosDomain = 'heos';
const heosSsdpSt = 'urn:schemas-denon-com:device:ACT-Denon:1';
const heosMdnsType = '_heos-audio._tcp.local.';
export class HeosSsdpMatcher implements IDiscoveryMatcher<IHeosSsdpRecord> {
public id = 'heos-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Denon HEOS SSDP advertisements.';
public async matches(recordArg: IHeosSsdpRecord): Promise<IDiscoveryMatch> {
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
const usn = header(recordArg, 'usn');
const location = header(recordArg, 'location');
const manufacturer = upnp(recordArg, 'manufacturer');
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial');
const friendlyName = upnp(recordArg, 'friendlyName');
const haystack = `${st || ''} ${usn || ''} ${manufacturer || ''} ${model || ''} ${friendlyName || ''}`.toLowerCase();
const matched = normalizeUrn(st) === normalizeUrn(heosSsdpSt)
|| haystack.includes('act-denon')
|| haystack.includes('heos');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not a HEOS advertisement.' };
}
const url = parseUrl(location);
const id = serialNumber || stripUuid(usn) || model;
return {
matched: true,
confidence: normalizeUrn(st) === normalizeUrn(heosSsdpSt) && url?.hostname ? 'certain' : url?.hostname ? 'high' : 'medium',
reason: 'SSDP record matches Denon HEOS metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: heosDomain,
id,
host: url?.hostname,
port: heosDefaultPort,
name: friendlyName,
manufacturer: normalizedManufacturer(manufacturer),
model,
serialNumber,
metadata: { st, usn, location, deviceType: upnp(recordArg, 'deviceType') },
},
};
}
}
export class HeosMdnsMatcher implements IDiscoveryMatcher<IHeosMdnsRecord> {
public id = 'heos-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize HEOS zeroconf advertisements.';
public async matches(recordArg: IHeosMdnsRecord): Promise<IDiscoveryMatch> {
const type = recordArg.type || '';
const txt = recordArg.txt || {};
const model = valueForKey(txt, 'model') || valueForKey(txt, 'modelName');
const serialNumber = valueForKey(txt, 'serial') || valueForKey(txt, 'serialNumber');
const manufacturer = valueForKey(txt, 'manufacturer') || valueForKey(txt, 'brand');
const haystack = `${recordArg.name || ''} ${type} ${manufacturer || ''} ${model || ''}`.toLowerCase();
const matched = normalizeMdnsType(type) === normalizeMdnsType(heosMdnsType) || haystack.includes('heos');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a HEOS advertisement.' };
}
const id = serialNumber || valueForKey(txt, 'id') || recordArg.name;
return {
matched: true,
confidence: recordArg.host ? 'certain' : 'high',
reason: 'mDNS record matches HEOS zeroconf metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: heosDomain,
id,
host: recordArg.host,
port: heosDefaultPort,
name: recordArg.name,
manufacturer: normalizedManufacturer(manufacturer),
model,
serialNumber,
metadata: { mdnsName: recordArg.name, mdnsType: type, advertisedPort: recordArg.port, txt },
},
};
}
}
export class HeosManualMatcher implements IDiscoveryMatcher<IHeosManualEntry> {
public id = 'heos-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual HEOS setup entries.';
public async matches(inputArg: IHeosManualEntry): Promise<IDiscoveryMatch> {
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.heos || haystack.includes('heos'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain HEOS setup hints.' };
}
const id = inputArg.serialNumber || inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || heosDefaultPort}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start HEOS setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: heosDomain,
id,
host: inputArg.host,
port: inputArg.port || heosDefaultPort,
name: inputArg.name,
manufacturer: normalizedManufacturer(inputArg.manufacturer),
model: inputArg.model,
serialNumber: inputArg.serialNumber,
metadata: { ...inputArg.metadata, username: inputArg.username ? true : undefined, password: inputArg.password ? true : undefined },
},
};
}
}
export class HeosCandidateValidator implements IDiscoveryValidator {
public id = 'heos-candidate-validator';
public description = 'Validate HEOS candidates.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const matched = candidateArg.integrationDomain === heosDomain
|| includesHeos(candidateArg.name)
|| includesHeos(candidateArg.model)
|| includesHeos(candidateArg.manufacturer)
|| Boolean(metadata.heos)
|| normalizeUrn(stringMetadata(metadata.st)) === normalizeUrn(heosSsdpSt)
|| normalizeMdnsType(stringMetadata(metadata.mdnsType) || '') === normalizeMdnsType(heosMdnsType);
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has HEOS metadata.' : 'Candidate is not HEOS.',
candidate: matched ? { ...candidateArg, port: candidateArg.port || heosDefaultPort } : undefined,
normalizedDeviceId: candidateArg.serialNumber || candidateArg.id,
};
}
}
export const createHeosDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: heosDomain, displayName: 'Denon HEOS' })
.addMatcher(new HeosSsdpMatcher())
.addMatcher(new HeosMdnsMatcher())
.addMatcher(new HeosManualMatcher())
.addValidator(new HeosCandidateValidator());
};
const header = (recordArg: IHeosSsdpRecord, keyArg: string): string | undefined => {
return recordArg[keyArg as keyof IHeosSsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg);
};
const upnp = (recordArg: IHeosSsdpRecord, keyArg: string): string | undefined => {
return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg);
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const parseUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const stripUuid = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/^uuid:/i, '').split('::')[0];
};
const normalizeUrn = (valueArg: string | undefined): string => {
return (valueArg || '').toLowerCase();
};
const normalizeMdnsType = (valueArg: string): string => {
return valueArg.toLowerCase().replace(/\.$/, '');
};
const normalizedManufacturer = (valueArg: string | undefined): string => {
if (!valueArg) {
return 'Denon';
}
if (valueArg.toLowerCase().includes('marantz')) {
return 'Marantz';
}
return valueArg.toLowerCase().includes('denon') ? 'Denon' : valueArg;
};
const includesHeos = (valueArg: string | undefined): boolean => {
return Boolean(valueArg?.toLowerCase().includes('heos'));
};
const stringMetadata = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' ? valueArg : undefined;
};
+322
View File
@@ -0,0 +1,322 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IHeosGroup, IHeosMediaItem, IHeosMusicSource, IHeosNowPlayingMedia, IHeosPlayer, IHeosSnapshot } from './heos.types.js';
const heosAuxInputSourceId = 1027;
export class HeosMapper {
public static toDevices(snapshotArg: IHeosSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.lastUpdated || new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.systemDeviceId(snapshotArg),
integrationDomain: 'heos',
name: this.systemName(snapshotArg),
protocol: 'unknown',
manufacturer: 'Denon',
model: 'HEOS System',
online: snapshotArg.players.some((playerArg) => playerArg.available !== false),
features: [
{ id: 'players', capability: 'sensor', name: 'Players', readable: true, writable: false },
{ id: 'groups', capability: 'sensor', name: 'Groups', readable: true, writable: false },
{ id: 'sources', capability: 'media', name: 'Sources', readable: true, writable: false },
{ id: 'signed_in', capability: 'sensor', name: 'Signed in', readable: true, writable: false },
],
state: [
{ featureId: 'players', value: snapshotArg.players.length, updatedAt },
{ featureId: 'groups', value: snapshotArg.groups?.length || 0, updatedAt },
{ featureId: 'sources', value: this.sourceList(snapshotArg).length, updatedAt },
{ featureId: 'signed_in', value: Boolean(snapshotArg.system.isSignedIn || snapshotArg.system.signedInUsername), updatedAt },
],
metadata: {
host: snapshotArg.system.currentHost || snapshotArg.system.host,
signedInUsername: snapshotArg.system.signedInUsername,
preferredHosts: snapshotArg.system.preferredHosts?.map((hostArg) => hostArg.ipAddress).filter(Boolean),
},
}];
for (const player of snapshotArg.players) {
const media = player.nowPlayingMedia;
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
{ id: 'group', capability: 'media', name: 'Group', readable: true, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'playback', value: this.mediaState(player), updatedAt },
{ featureId: 'volume', value: typeof player.volume === 'number' ? player.volume : null, updatedAt },
{ featureId: 'muted', value: typeof player.muted === 'boolean' ? player.muted : null, updatedAt },
{ featureId: 'source', value: this.currentSource(snapshotArg, media) || null, updatedAt },
{ featureId: 'group', value: player.groupId ?? null, updatedAt },
];
if (media?.song || media?.station) {
features.push({ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false });
state.push({ featureId: 'current_title', value: this.mediaTitle(media) || null, updatedAt });
}
devices.push({
id: this.playerDeviceId(player),
integrationDomain: 'heos',
name: player.name,
protocol: 'unknown',
manufacturer: this.manufacturer(player),
model: this.model(player),
online: player.available !== false,
features,
state,
metadata: {
playerId: player.playerId,
serialNumber: player.serial,
softwareVersion: player.version,
ipAddress: player.ipAddress,
network: player.network,
groupId: player.groupId,
viaDeviceId: this.systemDeviceId(snapshotArg),
},
});
}
return devices;
}
public static toEntities(snapshotArg: IHeosSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
for (const player of snapshotArg.players) {
const media = player.nowPlayingMedia;
const base = this.playerEntityBase(player);
entities.push({
id: `media_player.${base}`,
uniqueId: `heos_${this.slug(String(player.playerId))}`,
integrationDomain: 'heos',
deviceId: this.playerDeviceId(player),
platform: 'media_player',
name: player.name,
state: this.mediaState(player),
attributes: {
deviceClass: 'speaker',
playerId: player.playerId,
model: player.model,
serialNumber: player.serial,
ipAddress: player.ipAddress,
volumeLevel: this.volumeLevel(player),
volume: player.volume,
isVolumeMuted: player.muted,
source: this.currentSource(snapshotArg, media),
sourceList: this.sourceList(snapshotArg),
groupId: player.groupId,
groupMembers: this.groupMembers(snapshotArg, player),
groupRole: this.groupRole(snapshotArg, player),
repeat: this.repeatMode(player.repeat),
shuffle: player.shuffle,
mediaContentType: 'music',
mediaContentId: media?.mediaId,
mediaTitle: this.mediaTitle(media),
mediaArtist: media?.artist,
mediaAlbumName: media?.album,
mediaDuration: this.seconds(media?.duration),
mediaPosition: media?.duration ? this.seconds(media.currentPosition) : undefined,
mediaImageUrl: media?.imageUrl || undefined,
mediaAlbumId: media?.albumId,
mediaQueueId: media?.queueId,
mediaSourceId: media?.sourceId,
mediaStation: media?.station,
mediaType: media?.type,
supportedControls: media?.supportedControls,
playbackError: player.playbackError,
},
available: player.available !== false,
});
entities.push({
id: `sensor.${base}_heos_media`,
uniqueId: `heos_${this.slug(String(player.playerId))}_media`,
integrationDomain: 'heos',
deviceId: this.playerDeviceId(player),
platform: 'sensor',
name: `${player.name} HEOS Media`,
state: this.mediaTitle(media) || media?.station || 'None',
attributes: {
playerId: player.playerId,
media,
source: this.currentSource(snapshotArg, media),
},
available: player.available !== false,
});
}
entities.push({
id: `sensor.${this.slug(this.systemName(snapshotArg))}_sources`,
uniqueId: `heos_${this.systemUniqueBase(snapshotArg)}_sources`,
integrationDomain: 'heos',
deviceId: this.systemDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.systemName(snapshotArg)} Sources`,
state: this.sourceList(snapshotArg).length,
attributes: {
sourceList: this.sourceList(snapshotArg),
favorites: this.favoriteList(snapshotArg),
inputSources: snapshotArg.inputSources || [],
musicSources: snapshotArg.musicSources || [],
},
available: snapshotArg.players.some((playerArg) => playerArg.available !== false),
});
entities.push({
id: `sensor.${this.slug(this.systemName(snapshotArg))}_groups`,
uniqueId: `heos_${this.systemUniqueBase(snapshotArg)}_groups`,
integrationDomain: 'heos',
deviceId: this.systemDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.systemName(snapshotArg)} Groups`,
state: snapshotArg.groups?.length || 0,
attributes: {
groups: (snapshotArg.groups || []).map((groupArg) => ({
...groupArg,
members: this.groupEntityIds(snapshotArg, groupArg),
})),
},
available: snapshotArg.players.some((playerArg) => playerArg.available !== false),
});
return entities;
}
public static entityPlayerId(snapshotArg: IHeosSnapshot, entityIdArg: string): number | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
const playerId = entity?.attributes?.playerId;
return typeof playerId === 'number' ? playerId : undefined;
}
public static playerDeviceId(playerArg: IHeosPlayer): string {
return `heos.player.${this.slug(String(playerArg.playerId))}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'heos').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'heos';
}
private static mediaState(playerArg: IHeosPlayer): string {
if (playerArg.available === false) {
return 'off';
}
const state = playerArg.state?.toLowerCase();
if (state === 'play') {
return 'playing';
}
if (state === 'pause') {
return 'paused';
}
return 'idle';
}
private static currentSource(snapshotArg: IHeosSnapshot, mediaArg: IHeosNowPlayingMedia | undefined): string | undefined {
if (!mediaArg) {
return undefined;
}
if (mediaArg.sourceId === heosAuxInputSourceId) {
const byStation = snapshotArg.inputSources?.find((sourceArg) => sourceArg.name === mediaArg.station);
if (byStation) {
return byStation.name;
}
const byMediaId = snapshotArg.inputSources?.find((sourceArg) => sourceArg.mediaId === mediaArg.mediaId);
if (byMediaId) {
return byMediaId.name;
}
}
if (mediaArg.type === 'station') {
const favorite = this.favoriteList(snapshotArg).find((favoriteArg) => favoriteArg.name === mediaArg.station || favoriteArg.mediaId === mediaArg.albumId);
return favorite?.name;
}
return undefined;
}
private static sourceList(snapshotArg: IHeosSnapshot): string[] {
if (snapshotArg.sourceList) {
return [...snapshotArg.sourceList];
}
const names = [...this.favoriteList(snapshotArg).map((favoriteArg) => favoriteArg.name), ...(snapshotArg.inputSources || []).map((sourceArg) => sourceArg.name)];
return [...new Set(names.filter(Boolean))];
}
private static favoriteList(snapshotArg: IHeosSnapshot): IHeosMediaItem[] {
return Object.entries(snapshotArg.favorites || {})
.sort(([leftArg], [rightArg]) => Number(leftArg) - Number(rightArg))
.map(([, favoriteArg]) => favoriteArg);
}
private static groupMembers(snapshotArg: IHeosSnapshot, playerArg: IHeosPlayer): string[] | undefined {
const group = (snapshotArg.groups || []).find((groupArg) => groupArg.groupId === playerArg.groupId);
if (!group) {
return undefined;
}
return this.groupEntityIds(snapshotArg, group);
}
private static groupEntityIds(snapshotArg: IHeosSnapshot, groupArg: IHeosGroup): string[] {
const playerIds = [groupArg.leadPlayerId, ...groupArg.memberPlayerIds];
return playerIds.map((playerIdArg) => {
const player = snapshotArg.players.find((playerArg) => playerArg.playerId === playerIdArg);
return player ? `media_player.${this.playerEntityBase(player)}` : undefined;
}).filter((entityArg): entityArg is string => Boolean(entityArg));
}
private static groupRole(snapshotArg: IHeosSnapshot, playerArg: IHeosPlayer): string | undefined {
const group = (snapshotArg.groups || []).find((groupArg) => groupArg.groupId === playerArg.groupId);
if (!group) {
return undefined;
}
return group.leadPlayerId === playerArg.playerId ? 'leader' : 'member';
}
private static mediaTitle(mediaArg: IHeosNowPlayingMedia | undefined): string | undefined {
return mediaArg?.song || mediaArg?.station;
}
private static repeatMode(repeatArg: string | undefined): 'off' | 'one' | 'all' | undefined {
if (!repeatArg) {
return undefined;
}
if (repeatArg === 'on_one') {
return 'one';
}
if (repeatArg === 'on_all') {
return 'all';
}
return 'off';
}
private static volumeLevel(playerArg: IHeosPlayer): number | undefined {
return typeof playerArg.volume === 'number' ? Math.max(0, Math.min(1, playerArg.volume / 100)) : undefined;
}
private static seconds(valueArg: number | undefined): number | undefined {
return typeof valueArg === 'number' ? Math.floor(valueArg / 1000) : undefined;
}
private static playerEntityBase(playerArg: IHeosPlayer): string {
return this.slug(playerArg.name || String(playerArg.playerId));
}
private static systemDeviceId(snapshotArg: IHeosSnapshot): string {
return `heos.system.${this.systemUniqueBase(snapshotArg)}`;
}
private static systemUniqueBase(snapshotArg: IHeosSnapshot): string {
return this.slug(snapshotArg.system.host || snapshotArg.system.currentHost || snapshotArg.players[0]?.serial || 'heos');
}
private static systemName(snapshotArg: IHeosSnapshot): string {
return snapshotArg.system.host ? 'HEOS System' : snapshotArg.players[0]?.name ? `${snapshotArg.players[0].name} HEOS System` : 'HEOS System';
}
private static manufacturer(playerArg: IHeosPlayer): string {
const modelParts = playerArg.model.split(/\s+/, 2);
return modelParts.length === 2 ? modelParts[0] : 'HEOS';
}
private static model(playerArg: IHeosPlayer): string {
const modelParts = playerArg.model.split(/\s+/, 2);
return modelParts.length === 2 ? modelParts[1] : playerArg.model;
}
}
+229 -3
View File
@@ -1,4 +1,230 @@
export interface IHomeAssistantHeosConfig { export const heosDefaultPort = 1255;
// TODO: replace with the TypeScript-native config for heos.
[key: string]: unknown; export type THeosPlayState = 'play' | 'pause' | 'stop' | 'unknown' | string;
export type THeosRepeatType = 'on_all' | 'on_one' | 'off' | string;
export type THeosMediaType =
| 'album'
| 'artist'
| 'container'
| 'dlna_server'
| 'genre'
| 'heos_server'
| 'heos_service'
| 'music_service'
| 'playlist'
| 'song'
| 'station'
| string;
export type THeosNetworkType = 'wired' | 'wifi' | 'unknown' | string;
export type THeosCommand =
| 'play'
| 'pause'
| 'stop'
| 'previous_track'
| 'next_track'
| 'volume_up'
| 'volume_down'
| 'set_volume'
| 'mute'
| 'select_source'
| 'play_media'
| 'play_preset'
| 'play_input'
| 'set_group'
| 'join'
| 'unjoin'
| 'group_volume_set'
| 'group_volume_up'
| 'group_volume_down';
export interface IHeosConfig {
host?: string;
port?: number;
name?: string;
username?: string;
password?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
timeoutMs?: number;
snapshot?: IHeosSnapshot;
commandExecutor?: IHeosCommandExecutor;
}
export interface IHomeAssistantHeosConfig extends IHeosConfig {}
export interface IHeosCommandExecutor {
execute(requestArg: IHeosRawCommandRequest): Promise<IHeosMessage | unknown>;
}
export interface IHeosRawCommandRequest {
command: string;
parameters: Record<string, string | number | boolean>;
uri: string;
host?: string;
port: number;
}
export interface IHeosCommandRequest {
command: THeosCommand;
playerId?: number;
groupId?: number;
playerIds?: number[];
source?: string;
mediaId?: string;
mediaType?: string;
preset?: number;
url?: string;
volumeLevel?: number;
volume?: number;
muted?: boolean;
step?: number;
}
export interface IHeosMessage {
command: string;
result: boolean;
message: Record<string, string>;
payload?: unknown;
options?: unknown;
raw?: string;
}
export interface IHeosSystemInfo {
host?: string;
currentHost?: string;
signedInUsername?: string;
isSignedIn?: boolean;
hosts?: IHeosHost[];
preferredHosts?: IHeosHost[];
}
export interface IHeosHost {
name: string;
model: string;
serial?: string;
version?: string;
ipAddress?: string;
network?: THeosNetworkType;
supportedVersion?: boolean;
preferredHost?: boolean;
}
export interface IHeosPlayer {
name: string;
playerId: number;
model: string;
serial?: string;
version?: string;
supportedVersion?: boolean;
ipAddress?: string;
network?: THeosNetworkType;
lineOut?: number | string;
control?: number | string;
state?: THeosPlayState;
volume?: number;
muted?: boolean;
repeat?: THeosRepeatType;
shuffle?: boolean;
playbackError?: string;
nowPlayingMedia?: IHeosNowPlayingMedia;
available?: boolean;
groupId?: number;
}
export interface IHeosNowPlayingMedia {
type?: THeosMediaType;
song?: string;
station?: string;
album?: string;
artist?: string;
imageUrl?: string;
albumId?: string;
mediaId?: string;
queueId?: number;
sourceId?: number;
currentPosition?: number;
duration?: number;
supportedControls?: string[];
}
export interface IHeosGroup {
name: string;
groupId: number;
leadPlayerId: number;
memberPlayerIds: number[];
volume?: number;
muted?: boolean;
}
export interface IHeosMediaBase {
sourceId: number;
name: string;
type: THeosMediaType;
imageUrl?: string;
}
export interface IHeosMusicSource extends IHeosMediaBase {
available?: boolean;
serviceUsername?: string;
}
export interface IHeosMediaItem extends IHeosMediaBase {
playable?: boolean;
browsable?: boolean;
containerId?: string;
mediaId?: string;
artist?: string;
album?: string;
albumId?: string;
}
export interface IHeosQueueItem {
queueId: number;
song?: string;
album?: string;
artist?: string;
imageUrl?: string;
mediaId?: string;
albumId?: string;
}
export interface IHeosSnapshot {
system: IHeosSystemInfo;
players: IHeosPlayer[];
groups?: IHeosGroup[];
musicSources?: IHeosMusicSource[];
favorites?: Record<number, IHeosMediaItem>;
inputSources?: IHeosMediaItem[];
sourceList?: string[];
lastUpdated?: string;
}
export interface IHeosSsdpRecord {
st?: string;
usn?: string;
location?: string;
headers?: Record<string, string | undefined>;
upnp?: Record<string, string | undefined>;
}
export interface IHeosMdnsRecord {
name?: string;
type?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
}
export interface IHeosManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
username?: string;
password?: string;
metadata?: Record<string, unknown>;
} }
+4
View File
@@ -1,2 +1,6 @@
export * from './heos.classes.integration.js'; export * from './heos.classes.integration.js';
export * from './heos.classes.client.js';
export * from './heos.classes.configflow.js';
export * from './heos.discovery.js';
export * from './heos.mapper.js';
export * from './heos.types.js'; export * from './heos.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './ipp.classes.client.js';
export * from './ipp.classes.configflow.js';
export * from './ipp.classes.integration.js'; export * from './ipp.classes.integration.js';
export * from './ipp.discovery.js';
export * from './ipp.mapper.js';
export * from './ipp.types.js'; export * from './ipp.types.js';
+653
View File
@@ -0,0 +1,653 @@
import type {
IIppAttributeRecord,
IIppClientLike,
IIppConfig,
IIppJobInfo,
IIppMarkerInfo,
IIppParsedResponse,
IIppPrinterInfo,
IIppSnapshot,
IIppStatusInfo,
TIppMarkerKind,
TIppPrinterState,
TIppSnapshotSource,
} from './ipp.types.js';
import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js';
const operationGetPrinterAttributes = 0x000b;
const groupOperationAttributes = 0x01;
const endOfAttributes = 0x03;
const valueTagInteger = 0x21;
const valueTagBoolean = 0x22;
const valueTagEnum = 0x23;
const valueTagDateTime = 0x31;
const valueTagKeyword = 0x44;
const valueTagCharset = 0x47;
const valueTagNaturalLanguage = 0x48;
const requestedPrinterAttributes = [
'printer-name',
'printer-info',
'printer-location',
'printer-make-and-model',
'printer-device-id',
'printer-uuid',
'printer-serial-number',
'printer-more-info',
'printer-uri-supported',
'document-format-supported',
'printer-state',
'printer-state-message',
'printer-state-reasons',
'printer-is-accepting-jobs',
'queued-job-count',
'printer-up-time',
'printer-current-time',
'marker-names',
'marker-types',
'marker-colors',
'marker-levels',
'marker-low-levels',
'marker-high-levels',
];
export class IppClient {
constructor(private readonly config: IIppConfig) {}
public async getSnapshot(): Promise<IIppSnapshot> {
if (this.config.snapshot) {
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
}
if (this.config.client) {
return this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client');
}
if (this.config.attributes) {
return this.normalizeSnapshot(this.snapshotFromAttributes(this.config.attributes, this.config.online ?? true, 'manual'), 'manual');
}
if (this.config.host) {
try {
return this.normalizeSnapshot(await this.fetchSnapshot(), 'ipp');
} catch (errorArg) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
}
}
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'IPP refresh requires config.host, config.snapshot, config.attributes, or config.client.'), 'runtime');
}
public async refresh(): Promise<IIppSnapshot> {
return this.getSnapshot();
}
public async ping(): Promise<boolean> {
const snapshot = await this.getSnapshot();
return snapshot.online && snapshot.source !== 'runtime' && !snapshot.error;
}
public hasUsableSource(): boolean {
return Boolean(this.config.host || this.config.snapshot || this.config.attributes || this.config.client);
}
public async destroy(): Promise<void> {}
public static parseResponse(dataArg: Uint8Array): IIppParsedResponse {
if (dataArg.length < 8) {
throw new Error('IPP response is too short.');
}
const data = Buffer.from(dataArg.buffer, dataArg.byteOffset, dataArg.byteLength);
const attributes: IIppAttributeRecord = {};
const version = `${data[0]}.${data[1]}`;
const statusCode = data.readUInt16BE(2);
const requestId = data.readUInt32BE(4);
let offset = 8;
let lastName = '';
while (offset < data.length) {
const tag = data[offset++];
if (tag === endOfAttributes) {
break;
}
if (isDelimiterTag(tag)) {
lastName = '';
continue;
}
if (offset + 2 > data.length) {
throw new Error('IPP response ended while reading attribute name length.');
}
const nameLength = data.readUInt16BE(offset);
offset += 2;
if (offset + nameLength + 2 > data.length) {
throw new Error('IPP response ended while reading attribute name.');
}
const name = nameLength ? data.subarray(offset, offset + nameLength).toString('utf8') : lastName;
offset += nameLength;
const valueLength = data.readUInt16BE(offset);
offset += 2;
if (offset + valueLength > data.length) {
throw new Error('IPP response ended while reading attribute value.');
}
const valueBytes = data.subarray(offset, offset + valueLength);
offset += valueLength;
if (!name) {
continue;
}
lastName = name;
addAttributeValue(attributes, name, parseIppValue(tag, valueBytes));
}
return { version, statusCode, requestId, attributes };
}
public static attributesToSnapshot(attributesArg: IIppAttributeRecord, configArg: Partial<IIppConfig> = {}, onlineArg = true, sourceArg: TIppSnapshotSource = 'manual', statusCodeArg?: number): IIppSnapshot {
return new IppClient(configArg).snapshotFromAttributes(attributesArg, onlineArg, sourceArg, statusCodeArg);
}
private async snapshotFromClient(clientArg: IIppClientLike): Promise<IIppSnapshot> {
const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.printer ? await clientArg.printer() : undefined;
if (!result) {
throw new Error('IPP client must expose getSnapshot() or printer().');
}
if (isIppSnapshot(result)) {
return result;
}
return this.snapshotFromAttributes(result, true, 'client');
}
private async fetchSnapshot(): Promise<IIppSnapshot> {
const requestBody = this.buildGetPrinterAttributesRequest();
const response = await this.fetchWithTimeout(this.endpointUrl(), {
method: 'POST',
headers: {
accept: 'application/ipp',
'content-type': 'application/ipp',
},
body: requestBody.buffer.slice(requestBody.byteOffset, requestBody.byteOffset + requestBody.byteLength) as ArrayBuffer,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`IPP printer request failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
const parsed = IppClient.parseResponse(new Uint8Array(await response.arrayBuffer()));
if (parsed.statusCode < 0x0000 || parsed.statusCode >= 0x0400) {
throw new Error(`IPP printer returned status 0x${parsed.statusCode.toString(16).padStart(4, '0')}.`);
}
return this.snapshotFromAttributes(parsed.attributes, true, 'ipp', parsed.statusCode);
}
private snapshotFromAttributes(attributesArg: IIppAttributeRecord, onlineArg: boolean, sourceArg: TIppSnapshotSource, statusCodeArg?: number): IIppSnapshot {
const attributes = normalizeAttributes(attributesArg);
const updatedAt = new Date().toISOString();
const deviceId = stringValue(firstValue(attributes['printer-device-id']));
const deviceInfo = parseDeviceId(deviceId);
const makeAndModel = stringValue(firstValue(attributes['printer-make-and-model']));
const uuid = this.config.uuid || stringValue(firstValue(attributes['printer-uuid']));
const serialNumber = this.config.serial || stringValue(firstValue(attributes['printer-serial-number'])) || deviceInfo.SERIALNUMBER || deviceInfo.SN || deviceInfo.SERN;
const printerState = printerStateValue(firstValue(attributes['printer-state']));
const currentTime = dateString(firstValue(attributes['printer-current-time']));
const uptimeSeconds = numberValue(firstValue(attributes['printer-up-time']));
const bootedAt = uptimeSeconds !== undefined ? new Date(Date.parse(currentTime || updatedAt) - uptimeSeconds * 1000).toISOString() : undefined;
const parsed = parsedHost(this.config.host);
const host = parsed?.host || this.config.host;
const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined);
const basePath = this.basePath();
const name = this.config.name || stringValue(firstValue(attributes['printer-name'])) || stringValue(firstValue(attributes['printer-info'])) || makeAndModel || host || 'IPP printer';
const printer: IIppPrinterInfo = {
id: this.config.uniqueId || uuid || serialNumber || (host ? `${host}:${port}${basePath}` : undefined) || name,
name,
manufacturer: this.config.manufacturer || deviceInfo.MFG || deviceInfo.MANUFACTURER || manufacturerFromMakeAndModel(makeAndModel),
model: this.config.model || deviceInfo.MDL || deviceInfo.MODEL || makeAndModel,
serialNumber,
uuid,
version: stringValue(firstValue(attributes['printer-firmware-name'])) || deviceInfo.VERSION,
location: stringValue(firstValue(attributes['printer-location'])),
info: stringValue(firstValue(attributes['printer-info'])),
moreInfo: stringValue(firstValue(attributes['printer-more-info'])),
makeAndModel,
deviceId,
commandSet: splitList(deviceInfo.CMD || deviceInfo.COMMANDSET || deviceInfo['COMMAND SET']),
uriSupported: stringValues(attributes['printer-uri-supported']),
host,
port,
basePath,
tls: this.tls(),
};
const status: IIppStatusInfo = {
printerState,
stateMessage: stringValue(firstValue(attributes['printer-state-message'])),
stateReasons: stringValues(attributes['printer-state-reasons']),
acceptingJobs: booleanValue(firstValue(attributes['printer-is-accepting-jobs'])),
queuedJobCount: numberValue(firstValue(attributes['queued-job-count'])),
uptimeSeconds,
bootedAt,
currentTime,
};
return {
printer,
status,
markers: this.markersFromAttributes(attributes),
jobs: this.jobsFromAttributes(attributes),
attributes,
online: onlineArg,
updatedAt,
source: sourceArg,
rawStatusCode: statusCodeArg,
};
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IIppSnapshot {
const parsed = parsedHost(this.config.host);
const host = parsed?.host || this.config.host;
const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined);
const basePath = this.basePath();
const name = this.config.name || this.config.model || host || 'IPP printer';
return {
printer: {
id: this.config.uniqueId || this.config.uuid || this.config.serial || (host ? `${host}:${port}${basePath}` : undefined) || name,
name,
manufacturer: this.config.manufacturer,
model: this.config.model,
serialNumber: this.config.serial,
uuid: this.config.uuid,
host,
port,
basePath,
tls: this.tls(),
},
status: {
printerState: 'unknown',
stateReasons: [],
},
markers: [],
jobs: [],
online: onlineArg,
updatedAt: new Date().toISOString(),
source: 'runtime',
error: errorArg,
};
}
private normalizeSnapshot(snapshotArg: IIppSnapshot, sourceArg: TIppSnapshotSource): IIppSnapshot {
const derived = snapshotArg.attributes ? this.snapshotFromAttributes(snapshotArg.attributes, snapshotArg.online, sourceArg, snapshotArg.rawStatusCode) : undefined;
const printer = {
...derived?.printer,
...snapshotArg.printer,
};
printer.name = printer.name || this.config.name || this.config.host || 'IPP printer';
printer.id = printer.id || this.config.uniqueId || printer.uuid || printer.serialNumber || printer.name;
printer.host = printer.host || this.config.host;
printer.port = printer.port || (printer.host ? this.config.port || ippDefaultPort : this.config.port);
printer.basePath = printer.basePath || this.basePath();
printer.tls = printer.tls ?? this.tls();
return {
...snapshotArg,
printer,
status: this.normalizeStatus(derived?.status, snapshotArg.status),
markers: snapshotArg.markers?.length ? snapshotArg.markers : derived?.markers || [],
jobs: snapshotArg.jobs?.length ? snapshotArg.jobs : derived?.jobs || [],
attributes: snapshotArg.attributes || derived?.attributes,
online: snapshotArg.online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
};
}
private markersFromAttributes(attributesArg: IIppAttributeRecord): IIppMarkerInfo[] {
const names = stringValues(attributesArg['marker-names']);
const types = stringValues(attributesArg['marker-types']);
const colors = stringValues(attributesArg['marker-colors']);
const levels = numberValues(attributesArg['marker-levels']);
const lowLevels = numberValues(attributesArg['marker-low-levels']);
const highLevels = numberValues(attributesArg['marker-high-levels']);
const count = Math.max(names.length, types.length, colors.length, levels.length, lowLevels.length, highLevels.length);
const markers: IIppMarkerInfo[] = [];
for (let index = 0; index < count; index++) {
const type = types[index];
const level = levels[index];
markers.push({
id: `marker_${index}`,
index,
name: names[index] || markerName(type, colors[index], index),
kind: markerKind(type, names[index]),
type,
color: colors[index],
level: level !== undefined && level >= 0 ? level : level !== undefined ? null : undefined,
lowLevel: lowLevels[index],
highLevel: highLevels[index],
});
}
return markers;
}
private jobsFromAttributes(attributesArg: IIppAttributeRecord): IIppJobInfo[] {
const ids = stringValues(attributesArg['job-id']);
const names = stringValues(attributesArg['job-name']);
const states = values(attributesArg['job-state']).map((valueArg) => jobStateValue(valueArg));
const owners = stringValues(attributesArg['job-originating-user-name']);
const impressionsCompleted = numberValues(attributesArg['job-impressions-completed']);
const createdAt = values(attributesArg['time-at-creation']).map((valueArg) => dateString(valueArg));
const processingAt = values(attributesArg['time-at-processing']).map((valueArg) => dateString(valueArg));
const completedAt = values(attributesArg['time-at-completed']).map((valueArg) => dateString(valueArg));
const count = Math.max(ids.length, names.length, states.length, owners.length, impressionsCompleted.length, createdAt.length, processingAt.length, completedAt.length);
const jobs: IIppJobInfo[] = [];
for (let index = 0; index < count; index++) {
jobs.push({
id: ids[index] || String(index + 1),
name: names[index],
state: states[index],
owner: owners[index],
impressionsCompleted: impressionsCompleted[index],
createdAt: createdAt[index],
processingAt: processingAt[index],
completedAt: completedAt[index],
});
}
return jobs;
}
private buildGetPrinterAttributesRequest(): Buffer {
const chunks: Buffer[] = [];
chunks.push(Buffer.from([0x01, 0x01]));
chunks.push(uint16(operationGetPrinterAttributes));
chunks.push(uint32(Math.floor(Math.random() * 0x7fffffff) + 1));
chunks.push(Buffer.from([groupOperationAttributes]));
writeAttribute(chunks, valueTagCharset, 'attributes-charset', 'utf-8');
writeAttribute(chunks, valueTagNaturalLanguage, 'attributes-natural-language', 'en');
writeAttribute(chunks, 0x45, 'printer-uri', this.printerUri());
requestedPrinterAttributes.forEach((attributeArg, indexArg) => writeAttribute(chunks, valueTagKeyword, indexArg === 0 ? 'requested-attributes' : '', attributeArg));
chunks.push(Buffer.from([endOfAttributes]));
return Buffer.concat(chunks);
}
private endpointUrl(): string {
const host = this.config.host;
if (!host) {
throw new Error('IPP host is required for live printer refresh.');
}
const parsed = parsedHost(host);
const tls = parsed?.tls ?? this.tls();
const endpointHost = parsed?.host || host;
const port = this.config.port || parsed?.port || ippDefaultPort;
const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath;
return `${tls ? 'https' : 'http'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`;
}
private printerUri(): string {
const host = this.config.host;
if (!host) {
throw new Error('IPP host is required for printer-uri.');
}
const parsed = parsedHost(host);
const tls = parsed?.tls ?? this.tls();
const endpointHost = parsed?.host || host;
const port = this.config.port || parsed?.port || ippDefaultPort;
const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath;
return `${tls ? 'ipps' : 'ipp'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`;
}
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || ippDefaultTimeoutMs);
try {
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
} finally {
clearTimeout(timeout);
}
}
private basePath(): string {
return normalizeBasePath(this.config.basePath || parsedHost(this.config.host)?.basePath || ippDefaultBasePath);
}
private tls(): boolean {
return this.config.tls ?? this.config.ssl ?? parsedHost(this.config.host)?.tls ?? false;
}
private cloneSnapshot(snapshotArg: IIppSnapshot): IIppSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IIppSnapshot;
}
private normalizeStatus(derivedArg: IIppStatusInfo | undefined, snapshotArg: IIppStatusInfo): IIppStatusInfo {
return {
...derivedArg,
...snapshotArg,
printerState: snapshotArg.printerState || derivedArg?.printerState || 'unknown',
stateReasons: snapshotArg.stateReasons || derivedArg?.stateReasons || [],
};
}
}
const isDelimiterTag = (tagArg: number): boolean => tagArg >= 0x01 && tagArg <= 0x0f;
const writeAttribute = (chunksArg: Buffer[], tagArg: number, nameArg: string, valueArg: string): void => {
const name = Buffer.from(nameArg, 'utf8');
const value = Buffer.from(valueArg, 'utf8');
chunksArg.push(Buffer.from([tagArg]));
chunksArg.push(uint16(name.length));
chunksArg.push(name);
chunksArg.push(uint16(value.length));
chunksArg.push(value);
};
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 parseIppValue = (tagArg: number, valueArg: Buffer): unknown => {
if (tagArg === 0x10 || tagArg === 0x12 || tagArg === 0x13) {
return null;
}
if ((tagArg === valueTagInteger || tagArg === valueTagEnum) && valueArg.length >= 4) {
return valueArg.readInt32BE(0);
}
if (tagArg === valueTagBoolean && valueArg.length >= 1) {
return valueArg[0] !== 0;
}
if (tagArg === valueTagDateTime && valueArg.length >= 11) {
return parseIppDateTime(valueArg);
}
if (tagArg === 0x33 && valueArg.length >= 8) {
return { lower: valueArg.readInt32BE(0), upper: valueArg.readInt32BE(4) };
}
if (tagArg === 0x32 && valueArg.length >= 9) {
return { x: valueArg.readInt32BE(0), y: valueArg.readInt32BE(4), units: valueArg[8] === 3 ? 'dpi' : 'dpcm' };
}
if (tagArg >= 0x40 || tagArg === 0x30 || tagArg === 0x35 || tagArg === 0x36) {
return valueArg.toString('utf8');
}
return valueArg.toString('hex');
};
const parseIppDateTime = (valueArg: Buffer): string | undefined => {
const year = valueArg.readUInt16BE(0);
const month = valueArg[2];
const day = valueArg[3];
const hour = valueArg[4];
const minute = valueArg[5];
const second = valueArg[6];
const decisecond = valueArg[7];
const direction = String.fromCharCode(valueArg[8]);
const offsetMinutes = valueArg[9] * 60 + valueArg[10];
const local = Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second, decisecond * 100);
const adjustment = direction === '+' ? -offsetMinutes : direction === '-' ? offsetMinutes : 0;
const timestamp = local + adjustment * 60_000;
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
};
const addAttributeValue = (attributesArg: IIppAttributeRecord, nameArg: string, valueArg: unknown): void => {
const existing = attributesArg[nameArg];
if (existing === undefined) {
attributesArg[nameArg] = valueArg;
return;
}
if (Array.isArray(existing)) {
existing.push(valueArg);
return;
}
attributesArg[nameArg] = [existing, valueArg];
};
const normalizeAttributes = (attributesArg: IIppAttributeRecord): IIppAttributeRecord => {
const normalized: IIppAttributeRecord = {};
for (const [key, value] of Object.entries(attributesArg || {})) {
normalized[key.trim()] = value;
}
return normalized;
};
const isIppSnapshot = (valueArg: IIppSnapshot | IIppAttributeRecord): valueArg is IIppSnapshot => {
return Boolean(valueArg && typeof valueArg === 'object' && 'printer' in valueArg && 'status' in valueArg && 'markers' in valueArg && 'online' in valueArg);
};
const values = (valueArg: unknown): unknown[] => Array.isArray(valueArg) ? valueArg : valueArg === undefined ? [] : [valueArg];
const firstValue = (valueArg: unknown): unknown => values(valueArg)[0];
const stringValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string' && valueArg.trim()) {
return valueArg.trim();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return String(valueArg);
}
return undefined;
};
const stringValues = (valueArg: unknown): string[] => values(valueArg).map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg));
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg.match(/[-+]?\d+(?:\.\d+)?/)?.[0]);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
const numberValues = (valueArg: unknown): number[] => values(valueArg).map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => itemArg !== undefined);
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
};
const dateString = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string') {
const parsed = Date.parse(valueArg);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
}
return undefined;
};
const printerStateValue = (valueArg: unknown): TIppPrinterState => {
if (typeof valueArg === 'number') {
return valueArg === 3 ? 'idle' : valueArg === 4 ? 'printing' : valueArg === 5 ? 'stopped' : 'unknown';
}
const value = stringValue(valueArg)?.toLowerCase();
return value === 'processing' ? 'printing' : value === 'idle' || value === 'printing' || value === 'stopped' ? value : value || 'unknown';
};
const jobStateValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'number') {
return ({ 3: 'pending', 4: 'pending-held', 5: 'processing', 6: 'processing-stopped', 7: 'canceled', 8: 'aborted', 9: 'completed' } as Record<number, string>)[valueArg] || String(valueArg);
}
return stringValue(valueArg);
};
const parseDeviceId = (valueArg: string | undefined): Record<string, string> => {
const result: Record<string, string> = {};
for (const part of (valueArg || '').split(';')) {
const separator = part.indexOf(':');
if (separator <= 0) {
continue;
}
const key = part.slice(0, separator).trim().toUpperCase().replace(/[\s-]+/g, '');
const value = part.slice(separator + 1).trim();
if (key && value) {
result[key] = value;
}
}
return result;
};
const splitList = (valueArg: string | undefined): string[] => (valueArg || '').split(/[,;]/).map((partArg) => partArg.trim()).filter(Boolean);
const manufacturerFromMakeAndModel = (valueArg: string | undefined): string | undefined => {
const firstWord = valueArg?.trim().split(/\s+/)[0];
return firstWord && firstWord.length > 1 ? firstWord : undefined;
};
const markerKind = (typeArg: string | undefined, nameArg: string | undefined): TIppMarkerKind => {
const haystack = `${typeArg || ''} ${nameArg || ''}`.toLowerCase();
if (haystack.includes('toner')) {
return 'toner';
}
if (haystack.includes('ink')) {
return 'ink';
}
if (haystack.includes('drum') || haystack.includes('opc')) {
return 'drum';
}
if (haystack.includes('waste')) {
return 'waste';
}
return 'marker';
};
const markerName = (typeArg: string | undefined, colorArg: string | undefined, indexArg: number): string => {
const parts = [colorArg, typeArg].filter(Boolean);
return parts.length ? parts.join(' ') : `Marker ${indexArg + 1}`;
};
const normalizeBasePath = (valueArg: string | undefined): string => {
const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath;
return value.startsWith('/') ? value : `/${value}`;
};
const parsedHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:'));
return {
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined,
tls: url.protocol === 'https:',
};
} catch {
return undefined;
}
};
const formatHost = (valueArg: string): string => valueArg.includes(':') && !valueArg.startsWith('[') ? `[${valueArg}]` : valueArg;
@@ -0,0 +1,116 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IIppAttributeRecord, IIppConfig, IIppSnapshot } from './ipp.types.js';
import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js';
export class IppConfigFlow implements IConfigFlow<IIppConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IIppConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect IPP printer',
description: 'Configure the local IPP printer endpoint.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'basePath', label: 'Relative path to the printer', type: 'text' },
{ name: 'tls', label: 'Use SSL/TLS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify SSL certificate', type: 'boolean' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotFromMetadata(metadata);
const attributes = attributesFromMetadata(metadata);
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.printer.host || '';
if (!host && !snapshot && !attributes && !metadata.client) {
return { kind: 'error', title: 'IPP setup failed', error: 'IPP setup requires a host, snapshot, attributes, or injected client.' };
}
const basePath = this.basePathValue(valuesArg.basePath) || this.stringMetadata(metadata, 'basePath') || snapshot?.printer.basePath || ippDefaultBasePath;
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.printer.port || ippDefaultPort;
const tls = this.booleanValue(valuesArg.tls) ?? this.booleanMetadata(metadata, 'tls') ?? snapshot?.printer.tls ?? false;
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(metadata, 'verifySsl') ?? false;
const uuid = this.stringMetadata(metadata, 'uuid') || snapshot?.printer.uuid;
const serial = candidateArg.serialNumber || snapshot?.printer.serialNumber;
return {
kind: 'done',
title: 'IPP printer configured',
config: {
host,
port,
basePath,
tls,
verifySsl,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.printer.name,
manufacturer: candidateArg.manufacturer || snapshot?.printer.manufacturer,
model: candidateArg.model || snapshot?.printer.model,
uniqueId: candidateArg.id || uuid || serial || (host ? `${host}:${port}${basePath}` : undefined),
uuid,
serial,
timeoutMs: ippDefaultTimeoutMs,
snapshot,
attributes,
client: metadata.client as IIppConfig['client'],
},
};
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
}
private basePathValue(valueArg: unknown): string | undefined {
const value = this.stringValue(valueArg);
if (!value) {
return undefined;
}
return value.startsWith('/') ? value : `/${value}`;
}
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
return this.stringValue(metadataArg[keyArg]);
}
private booleanMetadata(metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined {
const value = metadataArg[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
}
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IIppSnapshot | undefined => {
const snapshot = metadataArg.snapshot;
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined;
};
const attributesFromMetadata = (metadataArg: Record<string, unknown>): IIppAttributeRecord | undefined => {
const attributes = metadataArg.attributes;
return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined;
};
+80 -23
View File
@@ -1,26 +1,83 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { IppClient } from './ipp.classes.client.js';
import { IppConfigFlow } from './ipp.classes.configflow.js';
import { createIppDiscoveryDescriptor } from './ipp.discovery.js';
import { IppMapper } from './ipp.mapper.js';
import type { IIppConfig } from './ipp.types.js';
export class HomeAssistantIppIntegration extends DescriptorOnlyIntegration { export class IppIntegration extends BaseIntegration<IIppConfig> {
constructor() { public readonly domain = 'ipp';
super({ public readonly displayName = 'Internet Printing Protocol (IPP)';
domain: "ipp", public readonly status = 'read-only-runtime' as const;
displayName: "Internet Printing Protocol (IPP)", public readonly discoveryDescriptor = createIppDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new IppConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/ipp", upstreamPath: 'homeassistant/components/ipp',
"upstreamDomain": "ipp", upstreamDomain: 'ipp',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['pyipp==0.17.0'],
"pyipp==0.17.0" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@ctalkington'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/ipp',
"@ctalkington" zeroconf: ['_ipps._tcp.local.', '_ipp._tcp.local.'],
] runtime: {
}, type: 'read-only-runtime',
}); polling: 'local HTTP(S) IPP Get-Printer-Attributes request',
services: ['snapshot', 'status', 'refresh'],
controls: false,
},
};
public async setup(configArg: IIppConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new IppRuntime(new IppClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantIppIntegration extends IppIntegration {}
class IppRuntime implements IIntegrationRuntime {
public domain = 'ipp';
constructor(private readonly client: IppClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return IppMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return IppMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain !== 'ipp') {
return { success: false, error: `Unsupported IPP service domain: ${requestArg.domain}` };
}
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'refresh') {
const snapshot = await this.client.refresh();
return snapshot.source !== 'runtime' || snapshot.online
? { success: true, data: snapshot }
: { success: false, error: snapshot.error || 'IPP refresh requires a host, snapshot, attributes, or client.', data: snapshot };
}
return { success: false, error: `Unsupported IPP service: ${requestArg.service}` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
} }
} }
+219
View File
@@ -0,0 +1,219 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IIppAttributeRecord, IIppManualEntry, IIppMdnsRecord, IIppSnapshot } from './ipp.types.js';
import { ippDefaultBasePath, ippDefaultPort } from './ipp.types.js';
const ippMdnsTypes = ['_ipp._tcp.local', '_ipps._tcp.local'];
export class IppMdnsMatcher implements IDiscoveryMatcher<IIppMdnsRecord> {
public id = 'ipp-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize IPP and IPPS printer mDNS advertisements.';
public async matches(recordArg: IIppMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeType(recordArg.type);
const properties = { ...recordArg.txt, ...recordArg.properties };
const matched = ippMdnsTypes.includes(type);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not an IPP printer advertisement.' };
}
const tls = type === '_ipps._tcp.local';
const uuid = valueForKey(properties, 'UUID') || valueForKey(properties, 'uuid');
const rp = valueForKey(properties, 'rp') || ippDefaultBasePath;
const name = cleanName(recordArg.name || recordArg.hostname, type);
return {
matched: true,
confidence: uuid ? 'certain' : 'high',
reason: 'mDNS record matches IPP printer service metadata.',
normalizedDeviceId: uuid || recordArg.host || recordArg.addresses?.[0],
candidate: {
source: 'mdns',
integrationDomain: 'ipp',
id: uuid,
host: recordArg.host || recordArg.addresses?.[0],
port: recordArg.port || ippDefaultPort,
name: name || valueForKey(properties, 'ty'),
manufacturer: valueForKey(properties, 'usb_MFG'),
model: valueForKey(properties, 'usb_MDL') || valueForKey(properties, 'ty') || valueForKey(properties, 'product'),
metadata: {
ipp: true,
mdnsName: recordArg.name,
mdnsType: recordArg.type,
txt: properties,
tls,
verifySsl: false,
basePath: normalizeBasePath(rp),
uuid,
rp,
},
},
};
}
}
export class IppManualMatcher implements IDiscoveryMatcher<IIppManualEntry> {
public id = 'ipp-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual IPP printer setup entries.';
public async matches(inputArg: IIppManualEntry): Promise<IDiscoveryMatch> {
const parsed = parseHost(inputArg.host);
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || snapshotFromMetadata(metadata);
const attributes = inputArg.attributes || attributesFromMetadata(metadata);
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.port === ippDefaultPort || inputArg.basePath || metadata.ipp || snapshot || attributes || haystack.includes('ipp') || haystack.includes('printer'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain IPP setup hints.' };
}
const port = inputArg.port || parsed?.port || ippDefaultPort;
const tls = inputArg.tls ?? inputArg.ssl ?? parsed?.tls ?? false;
const basePath = normalizeBasePath(inputArg.basePath || parsed?.basePath || stringMetadata(metadata, 'basePath') || ippDefaultBasePath);
const id = inputArg.id || inputArg.uuid || inputArg.serialNumber || snapshot?.printer.uuid || snapshot?.printer.serialNumber || (inputArg.host ? `${parsed?.host || inputArg.host}:${port}${basePath}` : undefined);
return {
matched: true,
confidence: inputArg.host || snapshot || attributes ? 'high' : 'medium',
reason: 'Manual entry can start IPP printer setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: 'ipp',
id,
host: parsed?.host || inputArg.host,
port,
name: inputArg.name || snapshot?.printer.name,
manufacturer: inputArg.manufacturer || snapshot?.printer.manufacturer,
model: inputArg.model || snapshot?.printer.model,
serialNumber: inputArg.serialNumber || snapshot?.printer.serialNumber,
metadata: {
...metadata,
ipp: true,
tls,
verifySsl: inputArg.verifySsl ?? booleanMetadata(metadata, 'verifySsl') ?? false,
basePath,
uuid: inputArg.uuid || snapshot?.printer.uuid,
snapshot,
attributes,
},
},
};
}
}
export class IppCandidateValidator implements IDiscoveryValidator {
public id = 'ipp-candidate-validator';
public description = 'Validate IPP candidates have printer metadata and a usable host, snapshot, attributes, or client.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const type = normalizeType(stringMetadata(metadata, 'mdnsType'));
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === 'ipp'
|| candidateArg.port === ippDefaultPort
|| Boolean(metadata.ipp)
|| ippMdnsTypes.includes(type)
|| haystack.includes('ipp')
|| haystack.includes('printer');
const hasUsableSource = Boolean(candidateArg.host || metadata.snapshot || metadata.attributes || metadata.client);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'IPP candidate lacks host, snapshot, attributes, or client information.' : 'Candidate is not IPP.',
};
}
const basePath = normalizeBasePath(stringMetadata(metadata, 'basePath') || ippDefaultBasePath);
const normalizedDeviceId = candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || ippDefaultPort}${basePath}` : undefined);
return {
matched: true,
confidence: candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber ? 'certain' : 'high',
reason: 'Candidate has IPP metadata and a usable source.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: 'ipp',
port: candidateArg.port || ippDefaultPort,
metadata: {
...metadata,
ipp: true,
basePath,
tls: booleanMetadata(metadata, 'tls') ?? false,
verifySsl: booleanMetadata(metadata, 'verifySsl') ?? false,
},
},
};
}
}
export const createIppDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'ipp', displayName: 'Internet Printing Protocol (IPP)' })
.addMatcher(new IppMdnsMatcher())
.addMatcher(new IppManualMatcher())
.addValidator(new IppCandidateValidator());
};
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const cleanName = (valueArg: string | undefined, typeArg: string): string => {
const escaped = typeArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return valueArg?.replace(new RegExp(`\\.${escaped}\\.?$`, 'i'), '').replace(/\.local\.?$/i, '').trim() || '';
};
const normalizeBasePath = (valueArg: string | undefined): string => {
const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath;
return value.startsWith('/') ? value : `/${value}`;
};
const parseHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:'));
return {
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined,
tls: url.protocol === 'https:',
};
} catch {
return undefined;
}
};
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const booleanMetadata = (metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'boolean' ? value : undefined;
};
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IIppSnapshot | undefined => {
const snapshot = metadataArg.snapshot;
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined;
};
const attributesFromMetadata = (metadataArg: Record<string, unknown>): IIppAttributeRecord | undefined => {
const attributes = metadataArg.attributes;
return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined;
};
+174
View File
@@ -0,0 +1,174 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IIppJobInfo, IIppMarkerInfo, IIppSnapshot } from './ipp.types.js';
const ippDomain = 'ipp';
export class IppMapper {
public static toDevices(snapshotArg: IIppSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'status', capability: 'sensor', name: 'Printer status', readable: true, writable: false },
{ id: 'queued_jobs', capability: 'sensor', name: 'Queued jobs', readable: true, writable: false },
{ id: 'accepting_jobs', capability: 'sensor', name: 'Accepting jobs', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'status', value: snapshotArg.status.printerState, updatedAt },
{ featureId: 'queued_jobs', value: snapshotArg.status.queuedJobCount ?? null, updatedAt },
{ featureId: 'accepting_jobs', value: snapshotArg.status.acceptingJobs ?? null, updatedAt },
];
for (const marker of snapshotArg.markers) {
const featureId = `marker_${marker.index}`;
features.push({ id: featureId, capability: 'sensor', name: marker.name, readable: true, writable: false, unit: '%' });
state.push({ featureId, value: marker.level ?? null, updatedAt });
}
for (const job of snapshotArg.jobs) {
const featureId = `job_${this.slug(job.id)}`;
features.push({ id: featureId, capability: 'sensor', name: job.name || `Job ${job.id}`, readable: true, writable: false });
state.push({ featureId, value: job.state || null, updatedAt });
}
return [{
id: this.printerDeviceId(snapshotArg),
integrationDomain: ippDomain,
name: this.printerName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.printer.manufacturer || 'Unknown',
model: snapshotArg.printer.model || snapshotArg.printer.makeAndModel || 'IPP printer',
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
serialNumber: snapshotArg.printer.serialNumber,
uuid: snapshotArg.printer.uuid,
location: snapshotArg.printer.location,
info: snapshotArg.printer.info,
moreInfo: snapshotArg.printer.moreInfo,
commandSet: snapshotArg.printer.commandSet,
uriSupported: snapshotArg.printer.uriSupported,
host: snapshotArg.printer.host,
port: snapshotArg.printer.port,
basePath: snapshotArg.printer.basePath,
tls: snapshotArg.printer.tls,
source: snapshotArg.source,
stateReasons: snapshotArg.status.stateReasons,
markerCount: snapshotArg.markers.length,
jobCount: snapshotArg.jobs.length,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IIppSnapshot): IIntegrationEntity[] {
const deviceId = this.printerDeviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const baseName = this.printerName(snapshotArg);
const entities: IIntegrationEntity[] = [];
entities.push(this.entity('sensor', 'printer', `${baseName} Status`, snapshotArg.status.printerState, deviceId, uniqueBase, snapshotArg.online, {
deviceClass: 'enum',
options: ['idle', 'printing', 'stopped'],
info: snapshotArg.printer.info,
serial: snapshotArg.printer.serialNumber,
uuid: snapshotArg.printer.uuid,
location: snapshotArg.printer.location,
stateMessage: snapshotArg.status.stateMessage,
stateReason: snapshotArg.status.stateReasons,
commandSet: snapshotArg.printer.commandSet,
uriSupported: snapshotArg.printer.uriSupported?.join(','),
manufacturer: snapshotArg.printer.manufacturer,
model: snapshotArg.printer.model,
source: snapshotArg.source,
error: snapshotArg.error,
}));
if (snapshotArg.status.bootedAt) {
entities.push(this.entity('sensor', 'uptime', `${baseName} Uptime`, snapshotArg.status.bootedAt, deviceId, uniqueBase, snapshotArg.online, {
deviceClass: 'timestamp',
entityCategory: 'diagnostic',
}));
}
if (snapshotArg.status.queuedJobCount !== undefined) {
entities.push(this.entity('sensor', 'queued_jobs', `${baseName} Queued Jobs`, snapshotArg.status.queuedJobCount, deviceId, uniqueBase, snapshotArg.online, {
stateClass: 'measurement',
}));
}
if (snapshotArg.status.acceptingJobs !== undefined) {
entities.push(this.entity('binary_sensor', 'accepting_jobs', `${baseName} Accepting Jobs`, snapshotArg.status.acceptingJobs ? 'on' : 'off', deviceId, uniqueBase, snapshotArg.online, {
deviceClass: 'running',
}));
}
for (const marker of snapshotArg.markers) {
entities.push(this.markerEntity(marker, deviceId, uniqueBase, snapshotArg.online));
}
for (const job of snapshotArg.jobs) {
entities.push(this.jobEntity(job, deviceId, uniqueBase, baseName, snapshotArg.online));
}
return entities;
}
public static printerDeviceId(snapshotArg: IIppSnapshot): string {
return `ipp.printer.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'ipp';
}
private static markerEntity(markerArg: IIppMarkerInfo, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean): IIntegrationEntity {
return this.entity('sensor', `marker_${markerArg.index}`, markerArg.name, markerArg.level ?? null, deviceIdArg, uniqueBaseArg, availableArg, {
unit: '%',
stateClass: 'measurement',
markerType: markerArg.type,
markerKind: markerArg.kind,
markerColor: markerArg.color,
markerLowLevel: markerArg.lowLevel,
markerHighLevel: markerArg.highLevel,
});
}
private static jobEntity(jobArg: IIppJobInfo, deviceIdArg: string, uniqueBaseArg: string, baseNameArg: string, availableArg: boolean): IIntegrationEntity {
return this.entity('sensor', `job_${this.slug(jobArg.id)}`, jobArg.name || `${baseNameArg} Job ${jobArg.id}`, jobArg.state || 'unknown', deviceIdArg, uniqueBaseArg, availableArg, {
jobId: jobArg.id,
owner: jobArg.owner,
impressionsCompleted: jobArg.impressionsCompleted,
createdAt: jobArg.createdAt,
processingAt: jobArg.processingAt,
completedAt: jobArg.completedAt,
...jobArg.attributes,
});
}
private static entity(platformArg: IIntegrationEntity['platform'], keyArg: string, nameArg: string, stateArg: unknown, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean, attributesArg: Record<string, unknown>): IIntegrationEntity {
return {
id: `${platformArg}.${this.slug(nameArg)}`,
uniqueId: `ipp_${uniqueBaseArg}_${keyArg}`,
integrationDomain: ippDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static printerName(snapshotArg: IIppSnapshot): string {
return snapshotArg.printer.name || snapshotArg.printer.model || snapshotArg.printer.host || 'IPP printer';
}
private static uniqueBase(snapshotArg: IIppSnapshot): string {
return this.slug(snapshotArg.printer.uuid || snapshotArg.printer.serialNumber || snapshotArg.printer.id || snapshotArg.printer.host || this.printerName(snapshotArg));
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
}
+141 -2
View File
@@ -1,4 +1,143 @@
export interface IHomeAssistantIppConfig { export const ippDefaultPort = 631;
// TODO: replace with the TypeScript-native config for ipp. export const ippDefaultBasePath = '/ipp/print';
export const ippDefaultTimeoutMs = 10000;
export type TIppSnapshotSource = 'ipp' | 'client' | 'snapshot' | 'manual' | 'runtime';
export type TIppPrinterState = 'idle' | 'printing' | 'stopped' | 'unknown' | string;
export type TIppMarkerKind = 'marker' | 'ink' | 'toner' | 'drum' | 'waste' | string;
export interface IIppConfig {
host?: string;
port?: number;
basePath?: string;
tls?: boolean;
ssl?: boolean;
verifySsl?: boolean;
timeoutMs?: number;
name?: string;
manufacturer?: string;
model?: string;
uniqueId?: string;
uuid?: string;
serial?: string;
snapshot?: IIppSnapshot;
attributes?: IIppAttributeRecord;
client?: IIppClientLike;
online?: boolean;
}
export interface IHomeAssistantIppConfig extends IIppConfig {}
export interface IIppClientLike {
printer?: () => Promise<IIppSnapshot | IIppAttributeRecord>;
getSnapshot?: () => Promise<IIppSnapshot | IIppAttributeRecord>;
}
export interface IIppAttributeRecord {
[key: string]: unknown; [key: string]: unknown;
} }
export interface IIppPrinterInfo {
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
uuid?: string;
version?: string;
location?: string;
info?: string;
moreInfo?: string;
makeAndModel?: string;
deviceId?: string;
commandSet?: string[];
uriSupported?: string[];
host?: string;
port?: number;
basePath?: string;
tls?: boolean;
}
export interface IIppStatusInfo {
printerState: TIppPrinterState;
stateMessage?: string;
stateReasons: string[];
acceptingJobs?: boolean;
queuedJobCount?: number;
uptimeSeconds?: number;
bootedAt?: string;
currentTime?: string;
}
export interface IIppMarkerInfo {
id?: string;
index: number;
name: string;
kind: TIppMarkerKind;
type?: string;
color?: string;
level?: number | null;
lowLevel?: number;
highLevel?: number;
}
export interface IIppJobInfo {
id: string;
name?: string;
state?: string;
owner?: string;
impressionsCompleted?: number;
createdAt?: string;
processingAt?: string;
completedAt?: string;
attributes?: Record<string, unknown>;
}
export interface IIppSnapshot {
printer: IIppPrinterInfo;
status: IIppStatusInfo;
markers: IIppMarkerInfo[];
jobs: IIppJobInfo[];
attributes?: IIppAttributeRecord;
online: boolean;
updatedAt?: string;
source?: TIppSnapshotSource;
error?: string;
rawStatusCode?: number;
}
export interface IIppParsedResponse {
version: string;
statusCode: number;
requestId: number;
attributes: IIppAttributeRecord;
}
export interface IIppMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IIppManualEntry {
host?: string;
port?: number;
basePath?: string;
tls?: boolean;
ssl?: boolean;
verifySsl?: boolean;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
uuid?: string;
snapshot?: IIppSnapshot;
attributes?: IIppAttributeRecord;
metadata?: Record<string, unknown>;
}