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